feat: separate create/edit form pages, Playwright test infrastructure, /auth/token endpoint

Move all CRUD create/edit forms from inline on list pages to dedicated form pages
with back-button navigation and post-save redirect. Add Playwright Docker container
(browser server on port 3000) with 25 passing E2E tests covering login, navigation,
and site CRUD workflows. Add POST /auth/token endpoint for clean JWT retrieval.
This commit is contained in:
Joseph Doherty
2026-03-21 15:17:24 -04:00
parent b3f8850711
commit d3194e3634
31 changed files with 2333 additions and 1117 deletions

View File

@@ -41,5 +41,6 @@
<Project Path="tests/ScadaLink.ManagementService.Tests/ScadaLink.ManagementService.Tests.csproj" />
<Project Path="tests/ScadaLink.IntegrationTests/ScadaLink.IntegrationTests.csproj" />
<Project Path="tests/ScadaLink.PerformanceTests/ScadaLink.PerformanceTests.csproj" />
<Project Path="tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj" />
</Folder>
</Solution>

View File

@@ -1,6 +1,6 @@
# Test Infrastructure
This document describes the local Docker-based test infrastructure for ScadaLink development. Six services provide the external dependencies needed to run and test the system locally. The first six run in `infra/docker-compose.yml`; Traefik runs alongside the cluster nodes in `docker/docker-compose.yml`.
This document describes the local Docker-based test infrastructure for ScadaLink development. Seven services provide the external dependencies needed to run and test the system locally. The first seven run in `infra/docker-compose.yml`; Traefik runs alongside the cluster nodes in `docker/docker-compose.yml`.
## Services
@@ -12,6 +12,7 @@ This document describes the local Docker-based test infrastructure for ScadaLink
| SMTP (Mailpit) | `axllent/mailpit:latest` | 1025 (SMTP), 8025 (web) | Environment vars | `infra/` |
| REST API (Flask) | Custom build (`infra/restapi/Dockerfile`) | 5200 | `infra/restapi/app.py` | `infra/` |
| LmxFakeProxy | Custom build (`infra/lmxfakeproxy/Dockerfile`) | 50051 (gRPC) | Environment vars | `infra/` |
| Playwright | `mcr.microsoft.com/playwright:v1.58.2-noble` | 3000 (WebSocket) | Command args | `infra/` |
| Traefik LB | `traefik:v3.4` | 9000 (proxy), 8180 (dashboard) | `docker/traefik/` | `docker/` |
## Quick Start
@@ -43,6 +44,7 @@ Each service has a dedicated document with configuration details, verification s
- [test_infra_smtp.md](test_infra_smtp.md) — SMTP test server (Mailpit)
- [test_infra_restapi.md](test_infra_restapi.md) — REST API test server (Flask)
- [test_infra_lmxfakeproxy.md](test_infra_lmxfakeproxy.md) — LmxProxy fake server (OPC UA bridge)
- [test_infra_playwright.md](test_infra_playwright.md) — Playwright browser server (Central UI testing)
- Traefik LB — see `docker/README.md` and `docker/traefik/` (runs with the cluster, not in `infra/`)
## Connection Strings
@@ -103,7 +105,7 @@ After a full teardown, the next `docker compose up -d` starts fresh — re-run t
```
infra/
docker-compose.yml # All five services
docker-compose.yml # All seven services
teardown.sh # Teardown script (volumes, images, venv)
glauth/config.toml # LDAP users and groups
mssql/setup.sql # Database and user creation

View File

@@ -0,0 +1,104 @@
# Test Infrastructure: Playwright Browser Server
## Overview
The Playwright browser server provides a remote headless browser (Chromium, Firefox, WebKit) that test scripts connect to over the network. It runs as a Playwright Server on port 3000, allowing UI tests for the Central UI (Blazor Server) to run from the host machine while the browser executes inside the container with access to the Docker network.
## Image & Ports
- **Image**: `mcr.microsoft.com/playwright:v1.58.2-noble` (Ubuntu 24.04 LTS)
- **Server port**: 3000 (Playwright Server WebSocket endpoint)
## Configuration
| Setting | Value | Description |
|---------|-------|-------------|
| `--host 0.0.0.0` | Bind address | Listen on all interfaces |
| `--port 3000` | Server port | Playwright Server WebSocket port |
| `ipc: host` | Docker IPC | Shared IPC namespace (required for Chromium) |
No additional config files are needed. The container runs `npx playwright run-server` on startup.
## Connecting from Test Scripts
Test scripts run on the host and connect to the browser server via WebSocket. The connection URL is:
```
ws://localhost:3000
```
### .NET (Microsoft.Playwright)
```csharp
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync("ws://localhost:3000");
var page = await browser.NewPageAsync();
// Browser runs inside Docker — use the Docker network hostname for Traefik.
await page.GotoAsync("http://scadalink-traefik");
```
### Node.js
```javascript
const { chromium } = require('playwright');
const browser = await chromium.connect('ws://localhost:3000');
const page = await browser.newPage();
await page.goto('http://scadalink-traefik');
```
### Python
```python
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.connect("ws://localhost:3000")
page = browser.new_page()
page.goto("http://scadalink-traefik")
```
## Central UI Access
The Playwright container is on the `scadalink-net` Docker network, so it can reach the Central UI cluster nodes directly:
| Target | URL in Test Scripts |
|--------|---------------------|
| Traefik LB | `http://scadalink-traefik` |
| Central Node A | `http://scadalink-central-a:5000` |
| Central Node B | `http://scadalink-central-b:5000` |
**Important**: The browser runs inside the Docker container, so `page.goto()` URLs must use Docker network hostnames (not `localhost`). The test script itself connects to the Playwright server via `ws://localhost:3000` (host-mapped port), but all URLs navigated by the browser resolve inside the container.
## Verification
1. Check the container is running:
```bash
docker ps --filter name=scadalink-playwright
```
2. Check the server is accepting connections (look for the WebSocket endpoint in logs):
```bash
docker logs scadalink-playwright 2>&1 | head -5
```
3. Quick smoke test with a one-liner (requires `npx` and `playwright` on the host):
```bash
npx playwright@1.58.2 test --browser chromium --connect ws://localhost:3000
```
## Relevance to ScadaLink Components
- **Central UI** — end-to-end UI testing of all Blazor Server pages (login, admin, design, deployment, monitoring workflows).
- **Traefik Proxy** — verify load balancer behavior, failover, and active node routing from a browser perspective.
## Notes
- The container includes Chromium, Firefox, and WebKit. Connect to the desired browser via `playwright.chromium.connect()`, `playwright.firefox.connect()`, or `playwright.webkit.connect()`.
- The `ipc: host` flag is required for Chromium to avoid out-of-memory crashes in the container.
- The Playwright Server version (`1.58.2`) must match the `@playwright` package version used by test scripts on the host.
- The container is stateless — no test data or browser state persists between restarts.
- To stop only the Playwright container: `cd infra && docker compose stop playwright`.

View File

@@ -88,6 +88,20 @@ services:
- scadalink-net
restart: unless-stopped
playwright:
image: mcr.microsoft.com/playwright:v1.58.2-noble
container_name: scadalink-playwright
ports:
- "3000:3000"
command: >
npx -y playwright@1.58.2 run-server
--host 0.0.0.0
--port 3000
ipc: host
networks:
- scadalink-net
restart: unless-stopped
volumes:
scadalink-mssql-data:

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Quick smoke test: verify Playwright can reach the Central UI through Traefik."""
import sys
from playwright.sync_api import sync_playwright
# The browser runs inside Docker, so use the Docker network hostname for Traefik.
# The Playwright server WebSocket is exposed to the host on port 3000.
TRAEFIK_URL = "http://scadalink-traefik"
PLAYWRIGHT_WS = "ws://localhost:3000"
def main():
with sync_playwright() as p:
print(f"Connecting to Playwright server at {PLAYWRIGHT_WS} ...")
browser = p.chromium.connect(PLAYWRIGHT_WS)
page = browser.new_page()
print(f"Navigating to {TRAEFIK_URL} ...")
response = page.goto(TRAEFIK_URL, wait_until="networkidle", timeout=15000)
status = response.status if response else None
title = page.title()
url = page.url
print(f" Status: {status}")
print(f" Title: {title}")
print(f" URL: {url}")
# Check for the login page (unauthenticated users get redirected)
has_login = page.locator("input[type='password'], form[action*='login'], button:has-text('Login'), button:has-text('Sign in')").count() > 0
if has_login:
print(" Login form detected: YES")
browser.close()
if status and 200 <= status < 400:
print("\nSMOKE TEST PASSED: Central UI is reachable through Traefik.")
return 0
else:
print(f"\nSMOKE TEST FAILED: unexpected status {status}")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -80,6 +80,47 @@ public static class AuthEndpoints
context.Response.Redirect("/");
}).DisableAntiforgery();
endpoints.MapPost("/auth/token", async (HttpContext context) =>
{
var form = await context.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
}
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
{
return Results.Json(
new { error = authResult.ErrorMessage ?? "Authentication failed." },
statusCode: 401);
}
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
var token = jwtService.GenerateToken(
authResult.DisplayName ?? username,
authResult.Username ?? username,
roleMappingResult.Roles,
roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds);
return Results.Json(new
{
access_token = token,
token_type = "Bearer",
username = authResult.Username ?? username,
display_name = authResult.DisplayName ?? username,
roles = roleMappingResult.Roles,
});
}).DisableAntiforgery();
endpoints.MapPost("/auth/logout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

View File

@@ -0,0 +1,144 @@
@page "/admin/api-keys/create"
@page "/admin/api-keys/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.InboundApi
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
@if (_saved)
{
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">&larr; Back to API Keys</a>
}
else
{
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">&larr; Back</a>
}
<h4 class="mb-0">@(_saved ? "API Key Created" : (_editingKey != null ? "Edit API Key" : "Add API Key"))</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_saved && _newlyCreatedKeyValue != null)
{
<div class="alert alert-success">
<strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1">
<code class="me-2">@_newlyCreatedKeyValue</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
</div>
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private ApiKey? _editingKey;
private string _formName = string.Empty;
private string? _formError;
private string? _errorMessage;
private string? _newlyCreatedKeyValue;
private bool _loading = true;
private bool _saved;
protected override async Task OnInitializedAsync()
{
try
{
if (Id.HasValue)
{
_editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value);
if (_editingKey == null)
{
_errorMessage = $"API key with ID {Id.Value} not found.";
}
else
{
_formName = _editingKey.Name;
}
}
}
catch (Exception ex)
{
_errorMessage = $"Failed to load API key: {ex.Message}";
}
_loading = false;
}
private async Task SaveKey()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
try
{
if (_editingKey != null)
{
_editingKey.Name = _formName.Trim();
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
await InboundApiRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/api-keys");
}
else
{
var keyValue = GenerateApiKey();
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true };
await InboundApiRepository.AddApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_newlyCreatedKeyValue = keyValue;
_saved = true;
}
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
private void CopyKeyToClipboard()
{
// Note: JS interop for clipboard would be needed for actual copy.
// For now the key is displayed for manual copy.
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
}
}

View File

@@ -4,11 +4,12 @@
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">API Key Management</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add API Key</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/api-keys/create")'>Add API Key</button>
</div>
<ToastNotification @ref="_toast" />
@@ -24,42 +25,6 @@
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingKey == null ? "Add New API Key" : "Edit API Key")</h6>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="col-md-4">
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
@if (_newlyCreatedKeyValue != null)
{
<div class="alert alert-success alert-dismissible fade show">
<strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1">
<code class="me-2">@_newlyCreatedKeyValue</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
<button type="button" class="btn-close" @onclick="() => _newlyCreatedKeyValue = null"></button>
</div>
}
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
@@ -95,7 +60,7 @@
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditKey(key)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
@if (key.IsEnabled)
{
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
@@ -121,12 +86,6 @@
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private ApiKey? _editingKey;
private string _formName = string.Empty;
private string? _formError;
private string? _newlyCreatedKeyValue;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
@@ -156,63 +115,6 @@
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..];
}
private void ShowAddForm()
{
_editingKey = null;
_formName = string.Empty;
_formError = null;
_showForm = true;
}
private void EditKey(ApiKey key)
{
_editingKey = key;
_formName = key.Name;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingKey = null;
_formError = null;
}
private async Task SaveKey()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
try
{
if (_editingKey != null)
{
_editingKey.Name = _formName.Trim();
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
}
else
{
var keyValue = GenerateApiKey();
var key = new ApiKey(_formName.Trim(), keyValue)
{
IsEnabled = true
};
await InboundApiRepository.AddApiKeyAsync(key);
_newlyCreatedKeyValue = keyValue;
}
await InboundApiRepository.SaveChangesAsync();
_showForm = false;
_editingKey = null;
_toast.ShowSuccess("API key saved.");
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task ToggleKey(ApiKey key)
{
try
@@ -247,18 +149,4 @@
}
}
private void CopyKeyToClipboard()
{
// Note: JS interop for clipboard would be needed for actual copy.
// For now the key is displayed for manual copy.
_toast.ShowInfo("Key displayed above. Select and copy manually.");
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
}
}

View File

@@ -0,0 +1,123 @@
@page "/admin/data-connections/create"
@page "/admin/data-connections/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-0">@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="mb-2">
<label class="form-label small">Protocol</label>
<select class="form-select form-select-sm" @bind="_formProtocol">
<option value="">Select...</option>
<option value="OpcUa">OPC UA</option>
<option value="LmxProxy">LMX Proxy</option>
<option value="Custom">Custom</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small">Configuration (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formConfiguration"
placeholder='e.g. {"endpoint":"opc.tcp://..."}' />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private DataConnection? _editingConnection;
private string _formName = string.Empty;
private string _formProtocol = string.Empty;
private string? _formConfiguration;
private string? _formError;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
if (_editingConnection != null)
{
_formName = _editingConnection.Name;
_formProtocol = _editingConnection.Protocol;
_formConfiguration = _editingConnection.Configuration;
}
}
catch (Exception ex)
{
_formError = $"Failed to load connection: {ex.Message}";
}
}
_loading = false;
}
private async Task SaveConnection()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; }
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = _formProtocol;
_editingConnection.Configuration = _formConfiguration?.Trim();
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
}
else
{
var conn = new DataConnection(_formName.Trim(), _formProtocol)
{
Configuration = _formConfiguration?.Trim()
};
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/data-connections");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/data-connections");
}
}

View File

@@ -4,11 +4,12 @@
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Data Connections</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Connection</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/data-connections/create")'>Add Connection</button>
</div>
<ToastNotification @ref="_toast" />
@@ -24,43 +25,6 @@
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingConnection == null ? "Add New Connection" : "Edit Connection")</h6>
<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="_formName" />
</div>
<div class="col-md-2">
<label class="form-label small">Protocol</label>
<select class="form-select form-select-sm" @bind="_formProtocol">
<option value="">Select...</option>
<option value="OpcUa">OPC UA</option>
<option value="LmxProxy">LMX Proxy</option>
<option value="Custom">Custom</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small">Configuration (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formConfiguration"
placeholder='e.g. {"endpoint":"opc.tcp://..."}' />
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
@* Assignment form *@
@if (_showAssignForm)
{
@@ -154,7 +118,7 @@
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditConnection(conn)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{conn.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteConnection(conn)">Delete</button>
</td>
@@ -172,13 +136,6 @@
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private DataConnection? _editingConnection;
private string _formName = string.Empty;
private string _formProtocol = string.Empty;
private string? _formConfiguration;
private string? _formError;
private bool _showAssignForm;
private int _assignConnectionId;
private int _assignSiteId;
@@ -224,67 +181,6 @@
_loading = false;
}
private void ShowAddForm()
{
_editingConnection = null;
_formName = string.Empty;
_formProtocol = string.Empty;
_formConfiguration = null;
_formError = null;
_showForm = true;
}
private void EditConnection(DataConnection conn)
{
_editingConnection = conn;
_formName = conn.Name;
_formProtocol = conn.Protocol;
_formConfiguration = conn.Configuration;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingConnection = null;
_formError = null;
}
private async Task SaveConnection()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; }
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = _formProtocol;
_editingConnection.Configuration = _formConfiguration?.Trim();
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
}
else
{
var conn = new DataConnection(_formName.Trim(), _formProtocol)
{
Configuration = _formConfiguration?.Trim()
};
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
_showForm = false;
_toast.ShowSuccess("Connection saved.");
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteConnection(DataConnection conn)
{
var confirmed = await _confirmDialog.ShowAsync(

View File

@@ -0,0 +1,213 @@
@page "/admin/ldap-mappings/create"
@page "/admin/ldap-mappings/{Id:int}/edit"
@using ScadaLink.Commons.Entities.Security
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISecurityRepository SecurityRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">
&larr; Back
</button>
</div>
<ConfirmDialog @ref="_confirmDialog" />
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(IsEditMode ? "Edit LDAP Mapping" : "Add LDAP Mapping")</h6>
<div class="mb-2">
<label class="form-label small">LDAP Group Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formGroupName" />
</div>
<div class="mb-2">
<label class="form-label small">Role</label>
<select class="form-select form-select-sm" @bind="_formRole">
<option value="">Select role...</option>
<option value="Admin">Admin</option>
<option value="Design">Design</option>
<option value="Deployment">Deployment</option>
</select>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveMapping">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
@if (IsEditMode && _formRole.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Site Scope Rules</h6>
@if (_scopeRules.Count > 0)
{
<table class="table table-sm table-striped table-hover mb-3">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Site ID</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var rule in _scopeRules)
{
<tr>
<td>@rule.Id</td>
<td>@rule.SiteId</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteScopeRule(rule)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-muted small mb-3">All sites (no restrictions)</p>
}
<div class="mb-2">
<label class="form-label small">Site ID</label>
<input type="number" class="form-control form-control-sm" @bind="_scopeRuleSiteId" />
</div>
@if (_scopeRuleError != null)
{
<div class="text-danger small mt-2">@_scopeRuleError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm" @onclick="AddScopeRule">Add</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool IsEditMode => Id.HasValue;
private LdapGroupMapping? _editingMapping;
private string _formGroupName = string.Empty;
private string _formRole = string.Empty;
private string? _formError;
private List<SiteScopeRule> _scopeRules = new();
private int _scopeRuleSiteId;
private string? _scopeRuleError;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
_editingMapping = await SecurityRepository.GetMappingByIdAsync(Id.Value);
if (_editingMapping != null)
{
_formGroupName = _editingMapping.LdapGroupName;
_formRole = _editingMapping.Role;
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
}
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/ldap-mappings");
}
private async Task SaveMapping()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formGroupName))
{
_formError = "LDAP Group Name is required.";
return;
}
if (string.IsNullOrWhiteSpace(_formRole))
{
_formError = "Role is required.";
return;
}
try
{
if (_editingMapping != null)
{
_editingMapping.LdapGroupName = _formGroupName.Trim();
_editingMapping.Role = _formRole;
await SecurityRepository.UpdateMappingAsync(_editingMapping);
}
else
{
var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole);
await SecurityRepository.AddMappingAsync(mapping);
}
await SecurityRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/ldap-mappings");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task AddScopeRule()
{
_scopeRuleError = null;
if (_scopeRuleSiteId <= 0)
{
_scopeRuleError = "Site ID must be a positive number.";
return;
}
try
{
var rule = new SiteScopeRule { LdapGroupMappingId = Id!.Value, SiteId = _scopeRuleSiteId };
await SecurityRepository.AddScopeRuleAsync(rule);
await SecurityRepository.SaveChangesAsync();
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
_scopeRuleSiteId = 0;
}
catch (Exception ex)
{
_scopeRuleError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteScopeRule(SiteScopeRule rule)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete scope rule for Site {rule.SiteId}? This cannot be undone.",
"Delete Scope Rule");
if (!confirmed) return;
try
{
await SecurityRepository.DeleteScopeRuleAsync(rule.Id);
await SecurityRepository.SaveChangesAsync();
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id!.Value)).ToList();
}
catch (Exception ex)
{
_scopeRuleError = $"Delete failed: {ex.Message}";
}
}
}

View File

@@ -4,11 +4,12 @@
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Security
@inject ISecurityRepository SecurityRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">LDAP Group Mappings</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Mapping</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/ldap-mappings/create")'>Add Mapping</button>
</div>
@if (_loading)
@@ -21,39 +22,6 @@
}
else
{
@* Add / Edit form *@
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingMapping == null ? "Add New Mapping" : "Edit Mapping")</h6>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">LDAP Group Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formGroupName" />
</div>
<div class="col-md-3">
<label class="form-label small">Role</label>
<select class="form-select form-select-sm" @bind="_formRole">
<option value="">Select role...</option>
<option value="Admin">Admin</option>
<option value="Design">Design</option>
<option value="Deployment">Deployment</option>
</select>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveMapping">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
@* Mappings table *@
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
@@ -95,13 +63,12 @@
}
@if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
{
<button class="btn btn-outline-info btn-sm ms-2 py-0 px-1"
@onclick="() => ShowScopeRuleForm(mapping.Id)">+ Scope</button>
<span class="text-muted small ms-2">(manage on edit page)</span>
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditMapping(mapping)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/ldap-mappings/{mapping.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteMapping(mapping.Id)">Delete</button>
</td>
@@ -110,29 +77,6 @@
</tbody>
</table>
@* Scope rule form *@
@if (_showScopeRuleForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Add Site Scope Rule (Mapping #@_scopeRuleMappingId)</h6>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Site ID</label>
<input type="number" class="form-control form-control-sm" @bind="_scopeRuleSiteId" />
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveScopeRule">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelScopeRuleForm">Cancel</button>
</div>
</div>
@if (_scopeRuleError != null)
{
<div class="text-danger small mt-1">@_scopeRuleError</div>
}
</div>
</div>
}
}
</div>
@@ -142,19 +86,6 @@
private bool _loading = true;
private string? _errorMessage;
// Mapping form state
private bool _showForm;
private LdapGroupMapping? _editingMapping;
private string _formGroupName = string.Empty;
private string _formRole = string.Empty;
private string? _formError;
// Scope rule form state
private bool _showScopeRuleForm;
private int _scopeRuleMappingId;
private int _scopeRuleSiteId;
private string? _scopeRuleError;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
@@ -184,71 +115,6 @@
_loading = false;
}
private void ShowAddForm()
{
_editingMapping = null;
_formGroupName = string.Empty;
_formRole = string.Empty;
_formError = null;
_showForm = true;
}
private void EditMapping(LdapGroupMapping mapping)
{
_editingMapping = mapping;
_formGroupName = mapping.LdapGroupName;
_formRole = mapping.Role;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingMapping = null;
_formError = null;
}
private async Task SaveMapping()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formGroupName))
{
_formError = "LDAP Group Name is required.";
return;
}
if (string.IsNullOrWhiteSpace(_formRole))
{
_formError = "Role is required.";
return;
}
try
{
if (_editingMapping != null)
{
_editingMapping.LdapGroupName = _formGroupName.Trim();
_editingMapping.Role = _formRole;
await SecurityRepository.UpdateMappingAsync(_editingMapping);
}
else
{
var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole);
await SecurityRepository.AddMappingAsync(mapping);
}
await SecurityRepository.SaveChangesAsync();
_showForm = false;
_editingMapping = null;
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteMapping(int id)
{
try
@@ -269,45 +135,4 @@
}
}
private void ShowScopeRuleForm(int mappingId)
{
_scopeRuleMappingId = mappingId;
_scopeRuleSiteId = 0;
_scopeRuleError = null;
_showScopeRuleForm = true;
}
private void CancelScopeRuleForm()
{
_showScopeRuleForm = false;
_scopeRuleError = null;
}
private async Task SaveScopeRule()
{
_scopeRuleError = null;
if (_scopeRuleSiteId <= 0)
{
_scopeRuleError = "Site ID must be a positive number.";
return;
}
try
{
var rule = new SiteScopeRule
{
LdapGroupMappingId = _scopeRuleMappingId,
SiteId = _scopeRuleSiteId
};
await SecurityRepository.AddScopeRuleAsync(rule);
await SecurityRepository.SaveChangesAsync();
_showScopeRuleForm = false;
await LoadDataAsync();
}
catch (Exception ex)
{
_scopeRuleError = $"Save failed: {ex.Message}";
}
}
}

View File

@@ -0,0 +1,163 @@
@page "/admin/sites/create"
@page "/admin/sites/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Communication
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">
&larr; Back
</button>
</div>
<ToastNotification @ref="_toast" />
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(IsEditMode ? "Edit Site" : "Add Site")</h6>
<div class="mb-2">
<label class="form-label small">Identifier</label>
<input type="text" class="form-control form-control-sm" @bind="_formIdentifier"
disabled="@IsEditMode" />
</div>
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="mb-3">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_formDescription" />
</div>
<h6 class="text-muted border-bottom pb-1">Node A</h6>
<div class="mb-2">
<label class="form-label small">Akka Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeAAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
<div class="mb-3">
<label class="form-label small">gRPC Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeAAddress"
placeholder="http://host:8083" />
</div>
<h6 class="text-muted border-bottom pb-1">Node B</h6>
<div class="mb-2">
<label class="form-label small">Akka Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeBAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
<div class="mb-3">
<label class="form-label small">gRPC Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeBAddress"
placeholder="http://host:8083" />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSite">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool IsEditMode => Id.HasValue;
private Site? _editingSite;
private string _formName = string.Empty;
private string _formIdentifier = string.Empty;
private string? _formDescription;
private string? _formNodeAAddress;
private string? _formNodeBAddress;
private string? _formGrpcNodeAAddress;
private string? _formGrpcNodeBAddress;
private string? _formError;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
_editingSite = await SiteRepository.GetSiteByIdAsync(Id.Value);
if (_editingSite != null)
{
_formName = _editingSite.Name;
_formIdentifier = _editingSite.SiteIdentifier;
_formDescription = _editingSite.Description;
_formNodeAAddress = _editingSite.NodeAAddress;
_formNodeBAddress = _editingSite.NodeBAddress;
_formGrpcNodeAAddress = _editingSite.GrpcNodeAAddress;
_formGrpcNodeBAddress = _editingSite.GrpcNodeBAddress;
}
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/sites");
}
private async Task SaveSite()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName))
{
_formError = "Name is required.";
return;
}
try
{
if (_editingSite != null)
{
_editingSite.Name = _formName.Trim();
_editingSite.Description = _formDescription?.Trim();
_editingSite.NodeAAddress = _formNodeAAddress?.Trim();
_editingSite.NodeBAddress = _formNodeBAddress?.Trim();
_editingSite.GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim();
_editingSite.GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim();
await SiteRepository.UpdateSiteAsync(_editingSite);
}
else
{
if (string.IsNullOrWhiteSpace(_formIdentifier))
{
_formError = "Identifier is required.";
return;
}
var site = new Site(_formName.Trim(), _formIdentifier.Trim())
{
Description = _formDescription?.Trim(),
NodeAAddress = _formNodeAAddress?.Trim(),
NodeBAddress = _formNodeBAddress?.Trim(),
GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(),
GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim()
};
await SiteRepository.AddSiteAsync(site);
}
await SiteRepository.SaveChangesAsync();
CommunicationService.RefreshSiteAddresses();
NavigationManager.NavigateTo("/admin/sites");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
}

View File

@@ -9,6 +9,7 @@
@inject ArtifactDeploymentService ArtifactDeploymentService
@inject CommunicationService CommunicationService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -22,7 +23,7 @@
}
Deploy Artifacts to All Sites
</button>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Site</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>Add Site</button>
</div>
</div>
@@ -39,62 +40,6 @@
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingSite == null ? "Add New Site" : "Edit Site")</h6>
<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="_formName" />
</div>
<div class="col-md-3">
<label class="form-label small">Identifier</label>
<input type="text" class="form-control form-control-sm" @bind="_formIdentifier"
disabled="@(_editingSite != null)" />
</div>
<div class="col-md-3">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_formDescription" />
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSite">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
<div class="row g-2 align-items-end mt-1">
<div class="col-md-6">
<label class="form-label small">Node A Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeAAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
<div class="col-md-6">
<label class="form-label small">Node B Address (optional)</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeBAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
</div>
<div class="row g-2 align-items-end mt-1">
<div class="col-md-6">
<label class="form-label small">gRPC Node A Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeAAddress"
placeholder="http://host:8083" />
</div>
<div class="col-md-6">
<label class="form-label small">gRPC Node B Address (optional)</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeBAddress"
placeholder="http://host:8083" />
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
@@ -146,7 +91,7 @@
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditSite(site)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/sites/{site.Id}/edit")'>Edit</button>
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
@onclick="() => DeployArtifacts(site)"
disabled="@_deploying">Deploy Artifacts</button>
@@ -172,17 +117,6 @@
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private Site? _editingSite;
private string _formName = string.Empty;
private string _formIdentifier = string.Empty;
private string? _formDescription;
private string? _formNodeAAddress;
private string? _formNodeBAddress;
private string? _formGrpcNodeAAddress;
private string? _formGrpcNodeBAddress;
private string? _formError;
private bool _deploying;
private ToastNotification _toast = default!;
@@ -217,94 +151,6 @@
_loading = false;
}
private void ShowAddForm()
{
_editingSite = null;
_formName = string.Empty;
_formIdentifier = string.Empty;
_formDescription = null;
_formNodeAAddress = null;
_formNodeBAddress = null;
_formGrpcNodeAAddress = null;
_formGrpcNodeBAddress = null;
_formError = null;
_showForm = true;
}
private void EditSite(Site site)
{
_editingSite = site;
_formName = site.Name;
_formIdentifier = site.SiteIdentifier;
_formDescription = site.Description;
_formNodeAAddress = site.NodeAAddress;
_formNodeBAddress = site.NodeBAddress;
_formGrpcNodeAAddress = site.GrpcNodeAAddress;
_formGrpcNodeBAddress = site.GrpcNodeBAddress;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingSite = null;
_formError = null;
}
private async Task SaveSite()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName))
{
_formError = "Name is required.";
return;
}
try
{
if (_editingSite != null)
{
_editingSite.Name = _formName.Trim();
_editingSite.Description = _formDescription?.Trim();
_editingSite.NodeAAddress = _formNodeAAddress?.Trim();
_editingSite.NodeBAddress = _formNodeBAddress?.Trim();
_editingSite.GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim();
_editingSite.GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim();
await SiteRepository.UpdateSiteAsync(_editingSite);
}
else
{
if (string.IsNullOrWhiteSpace(_formIdentifier))
{
_formError = "Identifier is required.";
return;
}
var site = new Site(_formName.Trim(), _formIdentifier.Trim())
{
Description = _formDescription?.Trim(),
NodeAAddress = _formNodeAAddress?.Trim(),
NodeBAddress = _formNodeBAddress?.Trim(),
GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(),
GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim()
};
await SiteRepository.AddSiteAsync(site);
}
await SiteRepository.SaveChangesAsync();
CommunicationService.RefreshSiteAddresses();
_showForm = false;
_editingSite = null;
_toast.ShowSuccess(_editingSite == null ? "Site created." : "Site updated.");
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteSite(Site site)
{
var confirmed = await _confirmDialog.ShowAsync(

View File

@@ -0,0 +1,143 @@
@page "/deployment/instances/create"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.TemplateEngine.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject InstanceService InstanceService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<a href="/deployment/instances" class="btn btn-outline-secondary btn-sm me-3">&larr; Back</a>
<h4 class="mb-0">Create Instance</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Instance Name</label>
<input type="text" class="form-control form-control-sm" @bind="_createName" placeholder="e.g. Motor-1" />
</div>
<div class="mb-2">
<label class="form-label small">Template</label>
<select class="form-select form-select-sm" @bind="_createTemplateId">
<option value="0">Select template...</option>
@foreach (var t in _templates)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_createSiteId">
<option value="0">Select site...</option>
@foreach (var s in _sites)
{
<option value="@s.Id">@s.Name</option>
}
</select>
</div>
<div class="mb-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>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="CreateInstance">Create</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<Area> _allAreas = new();
private bool _loading = true;
private string _createName = string.Empty;
private int _createTemplateId;
private int _createSiteId;
private int _createAreaId;
private string? _formError;
protected override async Task OnInitializedAsync()
{
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_allAreas.Clear();
foreach (var site in _sites)
{
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
_allAreas.AddRange(areas);
}
}
catch (Exception ex)
{
_formError = $"Failed to load data: {ex.Message}";
}
_loading = false;
}
private async Task CreateInstance()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_createName)) { _formError = "Instance name is required."; return; }
if (_createTemplateId == 0) { _formError = "Select a template."; return; }
if (_createSiteId == 0) { _formError = "Select a site."; return; }
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.CreateInstanceAsync(
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/deployment/instances");
}
else
{
_formError = result.Error;
}
}
catch (Exception ex)
{
_formError = $"Create failed: {ex.Message}";
}
}
private void GoBack() => NavigationManager.NavigateTo("/deployment/instances");
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
}

View File

@@ -15,11 +15,12 @@
@inject DeploymentService DeploymentService
@inject InstanceService InstanceService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Instances</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowCreateForm">Create Instance</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>Create Instance</button>
</div>
<ToastNotification @ref="_toast" />
@@ -35,59 +36,6 @@
}
else
{
@if (_showCreateForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Create Instance</h6>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Instance Name</label>
<input type="text" class="form-control form-control-sm" @bind="_createName" placeholder="e.g. Motor-1" />
</div>
<div class="col-md-3">
<label class="form-label small">Template</label>
<select class="form-select form-select-sm" @bind="_createTemplateId">
<option value="0">Select template...</option>
@foreach (var t in _templates)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_createSiteId">
<option value="0">Select site...</option>
@foreach (var s in _sites)
{
<option value="@s.Id">@s.Name</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Area</label>
<select class="form-select form-select-sm" @bind="_createAreaId">
<option value="0">No area</option>
@foreach (var a in _allAreas.Where(a => a.SiteId == _createSiteId))
{
<option value="@a.Id">@a.Name</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-success btn-sm me-1" @onclick="CreateInstance">Create</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showCreateForm = false">Cancel</button>
</div>
</div>
@if (_createError != null)
{
<div class="text-danger small mt-1">@_createError</div>
}
</div>
</div>
}
@* Filters *@
<div class="row mb-3 g-2">
<div class="col-md-2">
@@ -614,53 +562,6 @@
_actionInProgress = false;
}
// Create instance form
private bool _showCreateForm;
private string _createName = string.Empty;
private int _createTemplateId;
private int _createSiteId;
private int _createAreaId;
private string? _createError;
private void ShowCreateForm()
{
_createName = string.Empty;
_createTemplateId = 0;
_createSiteId = 0;
_createAreaId = 0;
_createError = null;
_showCreateForm = true;
}
private async Task CreateInstance()
{
_createError = null;
if (string.IsNullOrWhiteSpace(_createName)) { _createError = "Instance name is required."; return; }
if (_createTemplateId == 0) { _createError = "Select a template."; return; }
if (_createSiteId == 0) { _createError = "Select a site."; return; }
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.CreateInstanceAsync(
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
if (result.IsSuccess)
{
_showCreateForm = false;
_toast.ShowSuccess($"Instance '{_createName}' created.");
await LoadDataAsync();
}
else
{
_createError = result.Error;
}
}
catch (Exception ex)
{
_createError = $"Create failed: {ex.Message}";
}
}
// Override state
private int _overrideInstanceId;
private List<TemplateAttribute> _overrideAttrs = new();

View File

@@ -0,0 +1,126 @@
@page "/design/api-methods/create"
@page "/design/api-methods/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.InboundApi
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit API Method" : "Add API Method")</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" disabled="@Id.HasValue" />
</div>
<div class="mb-3">
<label class="form-label">Timeout (seconds)</label>
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
</div>
<div class="mb-3">
<label class="form-label">Params (JSON)</label>
<input type="text" class="form-control" @bind="_params" />
</div>
<div class="mb-3">
<label class="form-label">Returns (JSON)</label>
<input type="text" class="form-control" @bind="_returns" />
</div>
<div class="mb-3">
<label class="form-label">Script</label>
<textarea class="form-control font-monospace" rows="5" @bind="_script" style="font-size: 0.85rem;"></textarea>
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "", _script = "";
private int _timeoutSeconds = 30;
private string? _params, _returns;
private string? _formError;
private ApiMethod? _existing;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_existing = await InboundApiRepository.GetApiMethodByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
_script = _existing.Script;
_timeoutSeconds = _existing.TimeoutSeconds;
_params = _existing.ParameterDefinitions;
_returns = _existing.ReturnDefinition;
}
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_script))
{
_formError = "Name and script required.";
return;
}
try
{
if (_existing != null)
{
_existing.Script = _script;
_existing.TimeoutSeconds = _timeoutSeconds;
_existing.ParameterDefinitions = _params?.Trim();
_existing.ReturnDefinition = _returns?.Trim();
await InboundApiRepository.UpdateApiMethodAsync(_existing);
}
else
{
var m = new ApiMethod(_name.Trim(), _script)
{
TimeoutSeconds = _timeoutSeconds,
ParameterDefinitions = _params?.Trim(),
ReturnDefinition = _returns?.Trim()
};
await InboundApiRepository.AddApiMethodAsync(m);
}
await InboundApiRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
}

View File

@@ -0,0 +1,120 @@
@page "/design/db-connections/create"
@page "/design/db-connections/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.ExternalSystems
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IExternalSystemRepository ExternalSystemRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit Database Connection" : "Add Database Connection")</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" />
</div>
<div class="mb-3">
<label class="form-label">Connection String</label>
<input type="text" class="form-control" @bind="_connectionString" />
</div>
<div class="mb-3">
<label class="form-label">Max Retries</label>
<input type="number" class="form-control" @bind="_maxRetries" min="0" />
</div>
<div class="mb-3">
<label class="form-label">Retry Delay (seconds)</label>
<input type="number" class="form-control" @bind="_retryDelaySeconds" min="0" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "", _connectionString = "";
private int _maxRetries = 3;
private int _retryDelaySeconds = 5;
private string? _formError;
private DatabaseConnectionDefinition? _existing;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_existing = await ExternalSystemRepository.GetDatabaseConnectionByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
_connectionString = _existing.ConnectionString;
_maxRetries = _existing.MaxRetries;
_retryDelaySeconds = (int)_existing.RetryDelay.TotalSeconds;
}
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_connectionString))
{
_formError = "Name and connection string required.";
return;
}
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
_existing.ConnectionString = _connectionString.Trim();
_existing.MaxRetries = _maxRetries;
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_existing);
}
else
{
var dc = new DatabaseConnectionDefinition(_name.Trim(), _connectionString.Trim())
{
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds)
};
await ExternalSystemRepository.AddDatabaseConnectionAsync(dc);
}
await ExternalSystemRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
}

View File

@@ -0,0 +1,137 @@
@page "/design/external-systems/create"
@page "/design/external-systems/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.ExternalSystems
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IExternalSystemRepository ExternalSystemRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit External System" : "Add External System")</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" />
</div>
<div class="mb-3">
<label class="form-label">Endpoint URL</label>
<input type="text" class="form-control" @bind="_endpointUrl" />
</div>
<div class="mb-3">
<label class="form-label">Auth Type</label>
<select class="form-select" @bind="_authType">
<option>ApiKey</option>
<option>BasicAuth</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Auth Config (JSON)</label>
<input type="text" class="form-control" @bind="_authConfig" />
</div>
<div class="mb-3">
<label class="form-label">Max Retries</label>
<input type="number" class="form-control" @bind="_maxRetries" min="0" />
</div>
<div class="mb-3">
<label class="form-label">Retry Delay (seconds)</label>
<input type="number" class="form-control" @bind="_retryDelaySeconds" min="0" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "", _endpointUrl = "", _authType = "ApiKey";
private string? _authConfig;
private int _maxRetries = 3;
private int _retryDelaySeconds = 5;
private string? _formError;
private ExternalSystemDefinition? _existing;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_existing = await ExternalSystemRepository.GetExternalSystemByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
_endpointUrl = _existing.EndpointUrl;
_authType = _existing.AuthType;
_authConfig = _existing.AuthConfiguration;
_maxRetries = _existing.MaxRetries;
_retryDelaySeconds = (int)_existing.RetryDelay.TotalSeconds;
}
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_endpointUrl))
{
_formError = "Name and URL required.";
return;
}
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
_existing.EndpointUrl = _endpointUrl.Trim();
_existing.AuthType = _authType;
_existing.AuthConfiguration = _authConfig?.Trim();
_existing.MaxRetries = _maxRetries;
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
await ExternalSystemRepository.UpdateExternalSystemAsync(_existing);
}
else
{
var es = new ExternalSystemDefinition(_name.Trim(), _endpointUrl.Trim(), _authType)
{
AuthConfiguration = _authConfig?.Trim(),
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds)
};
await ExternalSystemRepository.AddExternalSystemAsync(es);
}
await ExternalSystemRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
}

View File

@@ -8,6 +8,7 @@
@inject IExternalSystemRepository ExternalSystemRepository
@inject INotificationRepository NotificationRepository
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<h4 class="mb-3">Integration Definitions</h4>
@@ -62,22 +63,9 @@
// External Systems
private List<ExternalSystemDefinition> _externalSystems = new();
private bool _showExtSysForm;
private ExternalSystemDefinition? _editingExtSys;
private string _extSysName = "", _extSysUrl = "", _extSysAuth = "ApiKey";
private string? _extSysAuthConfig;
private int _extSysMaxRetries = 3;
private int _extSysRetryDelaySeconds = 5;
private string? _extSysFormError;
// Database Connections
private List<DatabaseConnectionDefinition> _dbConnections = new();
private bool _showDbConnForm;
private DatabaseConnectionDefinition? _editingDbConn;
private string _dbConnName = "", _dbConnString = "";
private int _dbConnMaxRetries = 3;
private int _dbConnRetryDelaySeconds = 5;
private string? _dbConnFormError;
// SMTP Configuration
private List<SmtpConfiguration> _smtpConfigs = new();
@@ -92,26 +80,10 @@
// Notification Lists
private List<NotificationList> _notificationLists = new();
private bool _showNotifForm;
private NotificationList? _editingNotifList;
private string _notifName = "";
private string? _notifFormError;
// Notification Recipients
private Dictionary<int, List<NotificationRecipient>> _recipients = new();
private bool _showRecipientForm;
private int _recipientListId;
private string _recipientName = "", _recipientEmail = "";
private string? _recipientFormError;
// Inbound API Methods
private List<ApiMethod> _apiMethods = new();
private bool _showApiMethodForm;
private ApiMethod? _editingApiMethod;
private string _apiMethodName = "", _apiMethodScript = "";
private int _apiMethodTimeout = 30;
private string? _apiMethodParams, _apiMethodReturn;
private string? _apiMethodFormError;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
@@ -150,28 +122,9 @@
{
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">External Systems</h6>
<button class="btn btn-primary btn-sm" @onclick="ShowExtSysAddForm">Add</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/external-systems/create")'>Add</button>
</div>
@if (_showExtSysForm)
{
<div class="card mb-2"><div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-2"><label class="form-label small">Name</label><input type="text" class="form-control form-control-sm" @bind="_extSysName" /></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>
<select class="form-select form-select-sm" @bind="_extSysAuth"><option>ApiKey</option><option>BasicAuth</option></select></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-1"><label class="form-label small">Max Retries</label><input type="number" class="form-control form-control-sm" @bind="_extSysMaxRetries" min="0" /></div>
<div class="col-md-1"><label class="form-label small">Retry Delay (s)</label><input type="number" class="form-control form-control-sm" @bind="_extSysRetryDelaySeconds" min="0" /></div>
<div class="col-md-1">
<button class="btn btn-success btn-sm me-1" @onclick="SaveExtSys">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showExtSysForm = false">Cancel</button></div>
</div>
@if (_extSysFormError != null) { <div class="text-danger small mt-1">@_extSysFormError</div> }
</div></div>
}
<table class="table table-sm table-striped">
<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>
@@ -181,7 +134,7 @@
<td>@es.Name</td><td class="small">@es.EndpointUrl</td><td><span class="badge bg-secondary">@es.AuthType</span></td>
<td class="small">@es.MaxRetries</td><td class="small">@es.RetryDelay.TotalSeconds s</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingExtSys = es; _extSysName = es.Name; _extSysUrl = es.EndpointUrl; _extSysAuth = es.AuthType; _extSysAuthConfig = es.AuthConfiguration; _extSysMaxRetries = es.MaxRetries; _extSysRetryDelaySeconds = (int)es.RetryDelay.TotalSeconds; _showExtSysForm = true; }">Edit</button>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick='() => NavigationManager.NavigateTo($"/design/external-systems/{es.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteExtSys(es)">Delete</button>
</td>
</tr>
@@ -190,31 +143,6 @@
</table>
};
private void ShowExtSysAddForm()
{
_showExtSysForm = true;
_editingExtSys = null;
_extSysName = _extSysUrl = string.Empty;
_extSysAuth = "ApiKey";
_extSysAuthConfig = null;
_extSysMaxRetries = 3;
_extSysRetryDelaySeconds = 5;
_extSysFormError = null;
}
private async Task SaveExtSys()
{
_extSysFormError = null;
if (string.IsNullOrWhiteSpace(_extSysName) || string.IsNullOrWhiteSpace(_extSysUrl)) { _extSysFormError = "Name and URL required."; return; }
try
{
if (_editingExtSys != null) { _editingExtSys.Name = _extSysName.Trim(); _editingExtSys.EndpointUrl = _extSysUrl.Trim(); _editingExtSys.AuthType = _extSysAuth; _editingExtSys.AuthConfiguration = _extSysAuthConfig?.Trim(); _editingExtSys.MaxRetries = _extSysMaxRetries; _editingExtSys.RetryDelay = TimeSpan.FromSeconds(_extSysRetryDelaySeconds); await ExternalSystemRepository.UpdateExternalSystemAsync(_editingExtSys); }
else { var es = new ExternalSystemDefinition(_extSysName.Trim(), _extSysUrl.Trim(), _extSysAuth) { AuthConfiguration = _extSysAuthConfig?.Trim(), MaxRetries = _extSysMaxRetries, RetryDelay = TimeSpan.FromSeconds(_extSysRetryDelaySeconds) }; await ExternalSystemRepository.AddExternalSystemAsync(es); }
await ExternalSystemRepository.SaveChangesAsync(); _showExtSysForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
}
catch (Exception ex) { _extSysFormError = ex.Message; }
}
private async Task DeleteExtSys(ExternalSystemDefinition es)
{
if (!await _confirmDialog.ShowAsync($"Delete '{es.Name}'?", "Delete External System")) return;
@@ -227,25 +155,9 @@
{
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">Database Connections</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showDbConnForm = true; _editingDbConn = null; _dbConnName = _dbConnString = string.Empty; _dbConnMaxRetries = 3; _dbConnRetryDelaySeconds = 5; _dbConnFormError = null; }">Add</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/db-connections/create")'>Add</button>
</div>
@if (_showDbConnForm)
{
<div class="card mb-2"><div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3"><label class="form-label small">Name</label><input type="text" class="form-control form-control-sm" @bind="_dbConnName" /></div>
<div class="col-md-4"><label class="form-label small">Connection String</label><input type="text" class="form-control form-control-sm" @bind="_dbConnString" /></div>
<div class="col-md-1"><label class="form-label small">Max Retries</label><input type="number" class="form-control form-control-sm" @bind="_dbConnMaxRetries" min="0" /></div>
<div class="col-md-1"><label class="form-label small">Retry Delay (s)</label><input type="number" class="form-control form-control-sm" @bind="_dbConnRetryDelaySeconds" min="0" /></div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveDbConn">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showDbConnForm = false">Cancel</button></div>
</div>
@if (_dbConnFormError != null) { <div class="text-danger small mt-1">@_dbConnFormError</div> }
</div></div>
}
<table class="table table-sm table-striped">
<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>
@@ -255,7 +167,7 @@
<td>@dc.Name</td><td class="small text-muted text-truncate" style="max-width:400px;">@dc.ConnectionString</td>
<td class="small">@dc.MaxRetries</td><td class="small">@dc.RetryDelay.TotalSeconds s</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingDbConn = dc; _dbConnName = dc.Name; _dbConnString = dc.ConnectionString; _dbConnMaxRetries = dc.MaxRetries; _dbConnRetryDelaySeconds = (int)dc.RetryDelay.TotalSeconds; _showDbConnForm = true; }">Edit</button>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick='() => NavigationManager.NavigateTo($"/design/db-connections/{dc.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteDbConn(dc)">Delete</button>
</td>
</tr>
@@ -264,19 +176,6 @@
</table>
};
private async Task SaveDbConn()
{
_dbConnFormError = null;
if (string.IsNullOrWhiteSpace(_dbConnName) || string.IsNullOrWhiteSpace(_dbConnString)) { _dbConnFormError = "Name and connection string required."; return; }
try
{
if (_editingDbConn != null) { _editingDbConn.Name = _dbConnName.Trim(); _editingDbConn.ConnectionString = _dbConnString.Trim(); _editingDbConn.MaxRetries = _dbConnMaxRetries; _editingDbConn.RetryDelay = TimeSpan.FromSeconds(_dbConnRetryDelaySeconds); await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_editingDbConn); }
else { var dc = new DatabaseConnectionDefinition(_dbConnName.Trim(), _dbConnString.Trim()) { MaxRetries = _dbConnMaxRetries, RetryDelay = TimeSpan.FromSeconds(_dbConnRetryDelaySeconds) }; await ExternalSystemRepository.AddDatabaseConnectionAsync(dc); }
await ExternalSystemRepository.SaveChangesAsync(); _showDbConnForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
}
catch (Exception ex) { _dbConnFormError = ex.Message; }
}
private async Task DeleteDbConn(DatabaseConnectionDefinition dc)
{
if (!await _confirmDialog.ShowAsync($"Delete '{dc.Name}'?", "Delete DB Connection")) return;
@@ -289,44 +188,16 @@
{
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">Notification Lists</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showNotifForm = true; _editingNotifList = null; _notifName = string.Empty; _notifFormError = null; }">Add List</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/notification-lists/create")'>Add List</button>
</div>
@if (_showNotifForm)
{
<div class="card mb-2"><div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-4"><label class="form-label small">Name</label><input type="text" class="form-control form-control-sm" @bind="_notifName" /></div>
<div class="col-md-4">
<button class="btn btn-success btn-sm me-1" @onclick="SaveNotifList">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showNotifForm = false">Cancel</button></div>
</div>
@if (_notifFormError != null) { <div class="text-danger small mt-1">@_notifFormError</div> }
</div></div>
}
@if (_showRecipientForm)
{
<div class="card mb-2"><div class="card-body">
<h6 class="card-title small">Add Recipient</h6>
<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="_recipientName" /></div>
<div class="col-md-3"><label class="form-label small">Email</label><input type="email" class="form-control form-control-sm" @bind="_recipientEmail" /></div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveRecipient">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showRecipientForm = false">Cancel</button></div>
</div>
@if (_recipientFormError != null) { <div class="text-danger small mt-1">@_recipientFormError</div> }
</div></div>
}
@foreach (var list in _notificationLists)
{
<div class="card mb-2">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<strong>@list.Name</strong>
<div>
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1" @onclick="() => { _showRecipientForm = true; _recipientListId = list.Id; _recipientName = _recipientEmail = string.Empty; _recipientFormError = null; }">+ Recipient</button>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick='() => NavigationManager.NavigateTo($"/design/notification-lists/{list.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteNotifList(list)">Delete</button>
</div>
</div>
@@ -344,7 +215,6 @@
{
<span class="badge bg-light text-dark me-1 mb-1">
@r.Name &lt;@r.EmailAddress&gt;
<button type="button" class="btn-close ms-1" style="font-size: 0.5rem;" @onclick="() => DeleteRecipient(r)"></button>
</span>
}
}
@@ -353,19 +223,6 @@
}
};
private async Task SaveNotifList()
{
_notifFormError = null;
if (string.IsNullOrWhiteSpace(_notifName)) { _notifFormError = "Name required."; return; }
try
{
if (_editingNotifList != null) { _editingNotifList.Name = _notifName.Trim(); await NotificationRepository.UpdateNotificationListAsync(_editingNotifList); }
else { var nl = new NotificationList(_notifName.Trim()); await NotificationRepository.AddNotificationListAsync(nl); }
await NotificationRepository.SaveChangesAsync(); _showNotifForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
}
catch (Exception ex) { _notifFormError = ex.Message; }
}
private async Task DeleteNotifList(NotificationList list)
{
if (!await _confirmDialog.ShowAsync($"Delete notification list '{list.Name}'?", "Delete")) return;
@@ -373,54 +230,14 @@
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
private async Task SaveRecipient()
{
_recipientFormError = null;
if (string.IsNullOrWhiteSpace(_recipientName) || string.IsNullOrWhiteSpace(_recipientEmail)) { _recipientFormError = "Name and email required."; return; }
try
{
var r = new NotificationRecipient(_recipientName.Trim(), _recipientEmail.Trim()) { NotificationListId = _recipientListId };
await NotificationRepository.AddRecipientAsync(r); await NotificationRepository.SaveChangesAsync();
_showRecipientForm = false; _toast.ShowSuccess("Recipient added."); await LoadAllAsync();
}
catch (Exception ex) { _recipientFormError = ex.Message; }
}
private async Task DeleteRecipient(NotificationRecipient r)
{
try { await NotificationRepository.DeleteRecipientAsync(r.Id); await NotificationRepository.SaveChangesAsync(); _toast.ShowSuccess("Removed."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
// ==== Inbound API Methods ====
private RenderFragment RenderInboundApiMethods() => __builder =>
{
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">Inbound API Methods</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showApiMethodForm = true; _editingApiMethod = null; _apiMethodName = _apiMethodScript = string.Empty; _apiMethodTimeout = 30; _apiMethodParams = _apiMethodReturn = null; _apiMethodFormError = null; }">Add Method</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/api-methods/create")'>Add Method</button>
</div>
@if (_showApiMethodForm)
{
<div class="card mb-2"><div class="card-body">
<div class="row g-2">
<div class="col-md-3"><label class="form-label small">Name</label><input type="text" class="form-control form-control-sm" @bind="_apiMethodName" disabled="@(_editingApiMethod != null)" /></div>
<div class="col-md-2"><label class="form-label small">Timeout (s)</label><input type="number" class="form-control form-control-sm" @bind="_apiMethodTimeout" /></div>
<div class="col-md-3"><label class="form-label small">Params (JSON)</label><input type="text" class="form-control form-control-sm" @bind="_apiMethodParams" /></div>
<div class="col-md-3"><label class="form-label small">Returns (JSON)</label><input type="text" class="form-control form-control-sm" @bind="_apiMethodReturn" /></div>
</div>
<div class="mt-2">
<label class="form-label small">Script</label>
<textarea class="form-control form-control-sm font-monospace" rows="5" @bind="_apiMethodScript" style="font-size: 0.8rem;"></textarea>
</div>
<div class="mt-2">
<button class="btn btn-success btn-sm me-1" @onclick="SaveApiMethod">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showApiMethodForm = false">Cancel</button>
</div>
@if (_apiMethodFormError != null) { <div class="text-danger small mt-1">@_apiMethodFormError</div> }
</div></div>
}
<table class="table table-sm table-striped">
<thead class="table-dark"><tr><th>Name</th><th>Timeout</th><th>Script (preview)</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@@ -431,7 +248,7 @@
<td>@m.TimeoutSeconds s</td>
<td class="small font-monospace text-truncate" style="max-width:300px;">@m.Script[..Math.Min(60, m.Script.Length)]</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingApiMethod = m; _apiMethodName = m.Name; _apiMethodScript = m.Script; _apiMethodTimeout = m.TimeoutSeconds; _apiMethodParams = m.ParameterDefinitions; _apiMethodReturn = m.ReturnDefinition; _showApiMethodForm = true; }">Edit</button>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick='() => NavigationManager.NavigateTo($"/design/api-methods/{m.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteApiMethod(m)">Delete</button>
</td>
</tr>
@@ -440,19 +257,6 @@
</table>
};
private async Task SaveApiMethod()
{
_apiMethodFormError = null;
if (string.IsNullOrWhiteSpace(_apiMethodName) || string.IsNullOrWhiteSpace(_apiMethodScript)) { _apiMethodFormError = "Name and script required."; return; }
try
{
if (_editingApiMethod != null) { _editingApiMethod.Script = _apiMethodScript; _editingApiMethod.TimeoutSeconds = _apiMethodTimeout; _editingApiMethod.ParameterDefinitions = _apiMethodParams?.Trim(); _editingApiMethod.ReturnDefinition = _apiMethodReturn?.Trim(); await InboundApiRepository.UpdateApiMethodAsync(_editingApiMethod); }
else { var m = new ApiMethod(_apiMethodName.Trim(), _apiMethodScript) { TimeoutSeconds = _apiMethodTimeout, ParameterDefinitions = _apiMethodParams?.Trim(), ReturnDefinition = _apiMethodReturn?.Trim() }; await InboundApiRepository.AddApiMethodAsync(m); }
await InboundApiRepository.SaveChangesAsync(); _showApiMethodForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
}
catch (Exception ex) { _apiMethodFormError = ex.Message; }
}
private async Task DeleteApiMethod(ApiMethod m)
{
if (!await _confirmDialog.ShowAsync($"Delete API method '{m.Name}'?", "Delete")) return;

View File

@@ -0,0 +1,190 @@
@page "/design/notification-lists/create"
@page "/design/notification-lists/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Notifications
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject INotificationRepository NotificationRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit Notification List" : "Add Notification List")</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
@if (Id.HasValue)
{
<h5 class="mt-4 mb-3">Recipients</h5>
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_recipientName" />
</div>
<div class="mb-2">
<label class="form-label small">Email</label>
<input type="email" class="form-control form-control-sm" @bind="_recipientEmail" />
</div>
@if (_recipientFormError != null)
{
<div class="text-danger small mt-2">@_recipientFormError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm" @onclick="SaveRecipient">Add</button>
</div>
</div>
</div>
<table class="table table-sm table-striped">
<thead class="table-dark">
<tr>
<th>Name</th>
<th>Email</th>
<th style="width:80px;">Actions</th>
</tr>
</thead>
<tbody>
@if (_recipients.Count == 0)
{
<tr><td colspan="3" class="text-muted small">No recipients.</td></tr>
}
else
{
@foreach (var r in _recipients)
{
<tr>
<td>@r.Name</td>
<td>@r.EmailAddress</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteRecipient(r)">Delete</button>
</td>
</tr>
}
}
</tbody>
</table>
}
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "";
private string? _formError;
private NotificationList? _existing;
// Recipients
private List<NotificationRecipient> _recipients = new();
private string _recipientName = "", _recipientEmail = "";
private string? _recipientFormError;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_existing = await NotificationRepository.GetNotificationListByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
}
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name))
{
_formError = "Name required.";
return;
}
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
await NotificationRepository.UpdateNotificationListAsync(_existing);
}
else
{
var nl = new NotificationList(_name.Trim());
await NotificationRepository.AddNotificationListAsync(nl);
}
await NotificationRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
}
private async Task SaveRecipient()
{
_recipientFormError = null;
if (string.IsNullOrWhiteSpace(_recipientName) || string.IsNullOrWhiteSpace(_recipientEmail))
{
_recipientFormError = "Name and email required.";
return;
}
try
{
var r = new NotificationRecipient(_recipientName.Trim(), _recipientEmail.Trim())
{
NotificationListId = Id!.Value
};
await NotificationRepository.AddRecipientAsync(r);
await NotificationRepository.SaveChangesAsync();
_recipientName = _recipientEmail = string.Empty;
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
}
catch (Exception ex) { _recipientFormError = ex.Message; }
}
private async Task DeleteRecipient(NotificationRecipient r)
{
try
{
await NotificationRepository.DeleteRecipientAsync(r.Id);
await NotificationRepository.SaveChangesAsync();
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id!.Value)).ToList();
}
catch (Exception ex) { _recipientFormError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
}

View File

@@ -0,0 +1,194 @@
@page "/design/shared-scripts/create"
@page "/design/shared-scripts/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Scripts
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.TemplateEngine
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject SharedScriptService SharedScriptService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-0">@(Id.HasValue ? $"Edit Shared Script: {_formName}" : "New Shared Script")</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName"
disabled="@(Id.HasValue)" />
</div>
<div class="mb-2">
<label class="form-label small">Parameters (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formParameters"
placeholder='e.g. [{"name":"x","type":"Int32"}]' />
</div>
<div class="mb-2">
<label class="form-label small">Return Definition (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formReturn"
placeholder='e.g. {"type":"Boolean"}' />
</div>
<div class="mb-2">
<label class="form-label small">Code</label>
<textarea class="form-control form-control-sm font-monospace" rows="10" @bind="_formCode"
style="font-size: 0.8rem;"></textarea>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
@if (_syntaxCheckResult != null)
{
<div class="@(_syntaxCheckPassed ? "text-success" : "text-danger") small mt-1">@_syntaxCheckResult</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveScript">Save</button>
<button class="btn btn-outline-info btn-sm me-1" @onclick="CheckCompilation">Check Syntax</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading;
private string _formName = string.Empty;
private string _formCode = string.Empty;
private string? _formParameters;
private string? _formReturn;
private string? _formError;
private string? _syntaxCheckResult;
private bool _syntaxCheckPassed;
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
_loading = true;
try
{
var scripts = await SharedScriptService.GetAllSharedScriptsAsync();
var script = scripts.FirstOrDefault(s => s.Id == Id.Value);
if (script != null)
{
_formName = script.Name;
_formCode = script.Code;
_formParameters = script.ParameterDefinitions;
_formReturn = script.ReturnDefinition;
}
else
{
_formError = $"Shared script with ID {Id.Value} not found.";
}
}
catch (Exception ex)
{
_formError = $"Failed to load script: {ex.Message}";
}
_loading = false;
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/design/shared-scripts");
}
private void CheckCompilation()
{
var syntaxError = ValidateSyntaxLocally(_formCode);
if (syntaxError == null)
{
_syntaxCheckResult = "Syntax check passed.";
_syntaxCheckPassed = true;
}
else
{
_syntaxCheckResult = syntaxError;
_syntaxCheckPassed = false;
}
}
private async Task SaveScript()
{
_formError = null;
_syntaxCheckResult = null;
try
{
if (Id.HasValue)
{
var user = await GetCurrentUserAsync();
var result = await SharedScriptService.UpdateSharedScriptAsync(
Id.Value, _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/design/shared-scripts");
}
else
{
_formError = result.Error;
}
}
else
{
var user = await GetCurrentUserAsync();
var result = await SharedScriptService.CreateSharedScriptAsync(
_formName.Trim(), _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/design/shared-scripts");
}
else
{
_formError = result.Error;
}
}
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
/// <summary>
/// Basic syntax check: balanced braces/brackets/parens.
/// Mirrors the internal SharedScriptService.ValidateSyntax logic.
/// </summary>
private static string? ValidateSyntaxLocally(string code)
{
if (string.IsNullOrWhiteSpace(code)) return "Script code cannot be empty.";
int brace = 0, bracket = 0, paren = 0;
foreach (var ch in code)
{
switch (ch) { case '{': brace++; break; case '}': brace--; break; case '[': bracket++; break; case ']': bracket--; break; case '(': paren++; break; case ')': paren--; break; }
if (brace < 0) return "Syntax error: unmatched closing brace '}'.";
if (bracket < 0) return "Syntax error: unmatched closing bracket ']'.";
if (paren < 0) return "Syntax error: unmatched closing parenthesis ')'.";
}
if (brace != 0) return "Syntax error: unmatched opening brace '{'.";
if (bracket != 0) return "Syntax error: unmatched opening bracket '['.";
if (paren != 0) return "Syntax error: unmatched opening parenthesis '('.";
return null;
}
}

View File

@@ -7,11 +7,12 @@
@inject ITemplateEngineRepository TemplateEngineRepository
@inject SharedScriptService SharedScriptService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Shared Scripts</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">New Script</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/shared-scripts/create")'>New Script</button>
</div>
<ToastNotification @ref="_toast" />
@@ -27,50 +28,6 @@
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingScript == null ? "New Shared Script" : $"Edit: {_editingScript.Name}")</h6>
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName"
disabled="@(_editingScript != null)" />
</div>
<div class="col-md-4">
<label class="form-label small">Parameters (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formParameters"
placeholder='e.g. [{"name":"x","type":"Int32"}]' />
</div>
<div class="col-md-4">
<label class="form-label small">Return Definition (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formReturn"
placeholder='e.g. {"type":"Boolean"}' />
</div>
</div>
<div class="mt-2">
<label class="form-label small">Code</label>
<textarea class="form-control form-control-sm font-monospace" rows="10" @bind="_formCode"
style="font-size: 0.8rem;"></textarea>
</div>
<div class="mt-2">
<button class="btn btn-success btn-sm me-1" @onclick="SaveScript">Save</button>
<button class="btn btn-outline-info btn-sm me-1" @onclick="CheckCompilation">Check Syntax</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
@if (_syntaxCheckResult != null)
{
<div class="@(_syntaxCheckPassed ? "text-success" : "text-danger") small mt-1">@_syntaxCheckResult</div>
}
</div>
</div>
}
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
@@ -101,7 +58,7 @@
<td class="small text-muted">@(script.ReturnDefinition ?? "—")</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditScript(script)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/design/shared-scripts/{script.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteScript(script)">Delete</button>
</td>
@@ -123,16 +80,6 @@
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private SharedScript? _editingScript;
private string _formName = string.Empty;
private string _formCode = string.Empty;
private string? _formParameters;
private string? _formReturn;
private string? _formError;
private string? _syntaxCheckResult;
private bool _syntaxCheckPassed;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
@@ -156,118 +103,6 @@
_loading = false;
}
private void ShowAddForm()
{
_editingScript = null;
_formName = string.Empty;
_formCode = string.Empty;
_formParameters = null;
_formReturn = null;
_formError = null;
_syntaxCheckResult = null;
_showForm = true;
}
private void EditScript(SharedScript script)
{
_editingScript = script;
_formName = script.Name;
_formCode = script.Code;
_formParameters = script.ParameterDefinitions;
_formReturn = script.ReturnDefinition;
_formError = null;
_syntaxCheckResult = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingScript = null;
}
private void CheckCompilation()
{
var syntaxError = ValidateSyntaxLocally(_formCode);
if (syntaxError == null)
{
_syntaxCheckResult = "Syntax check passed.";
_syntaxCheckPassed = true;
}
else
{
_syntaxCheckResult = syntaxError;
_syntaxCheckPassed = false;
}
}
private async Task SaveScript()
{
_formError = null;
_syntaxCheckResult = null;
try
{
if (_editingScript != null)
{
var user = await GetCurrentUserAsync();
var result = await SharedScriptService.UpdateSharedScriptAsync(
_editingScript.Id, _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
if (result.IsSuccess)
{
_showForm = false;
_toast.ShowSuccess($"Script '{_editingScript.Name}' updated.");
await LoadDataAsync();
}
else
{
_formError = result.Error;
}
}
else
{
var user = await GetCurrentUserAsync();
var result = await SharedScriptService.CreateSharedScriptAsync(
_formName.Trim(), _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
if (result.IsSuccess)
{
_showForm = false;
_toast.ShowSuccess($"Script '{_formName}' created.");
await LoadDataAsync();
}
else
{
_formError = result.Error;
}
}
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
/// <summary>
/// Basic syntax check: balanced braces/brackets/parens.
/// Mirrors the internal SharedScriptService.ValidateSyntax logic.
/// </summary>
private static string? ValidateSyntaxLocally(string code)
{
if (string.IsNullOrWhiteSpace(code)) return "Script code cannot be empty.";
int brace = 0, bracket = 0, paren = 0;
foreach (var ch in code)
{
switch (ch) { case '{': brace++; break; case '}': brace--; break; case '[': bracket++; break; case ']': bracket--; break; case '(': paren++; break; case ')': paren--; break; }
if (brace < 0) return "Syntax error: unmatched closing brace '}'.";
if (bracket < 0) return "Syntax error: unmatched closing bracket ']'.";
if (paren < 0) return "Syntax error: unmatched closing parenthesis ')'.";
}
if (brace != 0) return "Syntax error: unmatched opening brace '{'.";
if (bracket != 0) return "Syntax error: unmatched opening bracket '['.";
if (paren != 0) return "Syntax error: unmatched opening parenthesis '('.";
return null;
}
private async Task DeleteScript(SharedScript script)
{
var confirmed = await _confirmDialog.ShowAsync(

View File

@@ -0,0 +1,117 @@
@page "/design/templates/create"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.TemplateEngine
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">&larr; Back</button>
</div>
<h4 class="mb-3">Create Template</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_createName" />
</div>
<div class="mb-2">
<label class="form-label small">Parent Template</label>
<select class="form-select form-select-sm" @bind="_createParentId">
<option value="0">(None - root template)</option>
@foreach (var t in _templates)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_createDescription" />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="CreateTemplate">Create</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
private List<Template> _templates = new();
private bool _loading = true;
private string _createName = string.Empty;
private int _createParentId;
private string? _createDescription;
private string? _formError;
protected override async Task OnInitializedAsync()
{
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
}
catch (Exception ex)
{
_formError = $"Failed to load templates: {ex.Message}";
}
_loading = false;
}
private async Task CreateTemplate()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_createName)) { _formError = "Name is required."; return; }
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.CreateTemplateAsync(
_createName.Trim(), _createDescription?.Trim(),
_createParentId == 0 ? null : _createParentId, user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/design/templates");
}
else
{
_formError = result.Error;
}
}
catch (Exception ex)
{
_formError = ex.Message;
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/design/templates");
}
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
}

View File

@@ -11,6 +11,7 @@
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
@@ -29,46 +30,9 @@
@* Template list view *@
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Templates</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowCreateForm">New Template</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>New Template</button>
</div>
@if (_showCreateForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Create Template</h6>
<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="_createName" />
</div>
<div class="col-md-3">
<label class="form-label small">Parent Template</label>
<select class="form-select form-select-sm" @bind="_createParentId">
<option value="0">(None - root template)</option>
@foreach (var t in _templates)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_createDescription" />
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="CreateTemplate">Create</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showCreateForm = false">Cancel</button>
</div>
</div>
@if (_createError != null)
{
<div class="text-danger small mt-1">@_createError</div>
}
</div>
</div>
}
@* Inheritance tree visualization *@
<div class="card">
<div class="card-body p-2">
@@ -254,13 +218,6 @@
private string? _errorMessage;
private string _activeTab = "attributes";
// Create form
private bool _showCreateForm;
private string _createName = string.Empty;
private int _createParentId;
private string? _createDescription;
private string? _createError;
// Edit properties
private string _editName = string.Empty;
private string? _editDescription;
@@ -377,44 +334,6 @@
_validationResult = null;
}
private void ShowCreateForm()
{
_createName = string.Empty;
_createParentId = 0;
_createDescription = null;
_createError = null;
_showCreateForm = true;
}
private async Task CreateTemplate()
{
_createError = null;
if (string.IsNullOrWhiteSpace(_createName)) { _createError = "Name is required."; return; }
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.CreateTemplateAsync(
_createName.Trim(), _createDescription?.Trim(),
_createParentId == 0 ? null : _createParentId, user);
if (result.IsSuccess)
{
_showCreateForm = false;
_toast.ShowSuccess($"Template '{_createName}' created.");
await LoadTemplatesAsync();
}
else
{
_createError = result.Error;
}
}
catch (Exception ex)
{
_createError = $"Create failed: {ex.Message}";
}
}
private async Task DeleteTemplate(Template template)
{
var confirmed = await _confirmDialog.ShowAsync(

View File

@@ -0,0 +1,115 @@
using System.Text.Json;
using Microsoft.Playwright;
namespace ScadaLink.CentralUI.PlaywrightTests;
[Collection("Playwright")]
public class LoginTests
{
private readonly PlaywrightFixture _fixture;
public LoginTests(PlaywrightFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task UnauthenticatedUser_RedirectsToLogin()
{
var page = await _fixture.NewPageAsync();
await page.GotoAsync(PlaywrightFixture.BaseUrl);
Assert.Contains("/login", page.Url);
await Expect(page.Locator("h4")).ToHaveTextAsync("ScadaLink");
await Expect(page.Locator("#username")).ToBeVisibleAsync();
await Expect(page.Locator("#password")).ToBeVisibleAsync();
await Expect(page.Locator("button[type='submit']")).ToHaveTextAsync("Sign In");
}
[Fact]
public async Task ValidCredentials_AuthenticatesSuccessfully()
{
var page = await _fixture.NewAuthenticatedPageAsync();
// Should be on the dashboard, not the login page
Assert.DoesNotContain("/login", page.Url);
}
[Fact]
public async Task InvalidCredentials_ShowsError()
{
var page = await _fixture.NewPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
// POST invalid credentials via fetch
var status = await page.EvaluateAsync<int>(@"
async () => {
const resp = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=baduser&password=badpass',
redirect: 'follow'
});
return resp.status;
}
");
// The login endpoint redirects to /login?error=... on failure.
// Reload the page to see the error state.
await page.ReloadAsync();
Assert.Equal(200, status); // redirect followed to login page
}
[Fact]
public async Task TokenEndpoint_ReturnsJwt()
{
var page = await _fixture.NewPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
var result = await page.EvaluateAsync<JsonElement>(@"
async () => {
const resp = await fetch('/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=multi-role&password=password'
});
const json = await resp.json();
return { status: resp.status, hasToken: !!json.access_token, username: json.username || '' };
}
");
Assert.Equal(200, result.GetProperty("status").GetInt32());
Assert.True(result.GetProperty("hasToken").GetBoolean());
Assert.Equal("multi-role", result.GetProperty("username").GetString());
}
[Fact]
public async Task TokenEndpoint_InvalidCredentials_Returns401()
{
var page = await _fixture.NewPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
var status = await page.EvaluateAsync<int>(@"
async () => {
const resp = await fetch('/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=baduser&password=badpass'
});
return resp.status;
}
");
Assert.Equal(401, status);
}
private static ILocatorAssertions Expect(ILocator locator) =>
Assertions.Expect(locator);
}

View File

@@ -0,0 +1,76 @@
using Microsoft.Playwright;
namespace ScadaLink.CentralUI.PlaywrightTests;
[Collection("Playwright")]
public class NavigationTests
{
private readonly PlaywrightFixture _fixture;
public NavigationTests(PlaywrightFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task Dashboard_IsVisibleAfterLogin()
{
var page = await _fixture.NewAuthenticatedPageAsync();
// The nav sidebar should be visible with the brand
await Expect(page.Locator(".brand")).ToHaveTextAsync("ScadaLink");
// The nav should contain "Dashboard" link (exact match to avoid "Health Dashboard")
await Expect(page.GetByRole(AriaRole.Link, new() { Name = "Dashboard", Exact = true })).ToBeVisibleAsync();
}
[Theory]
[InlineData("Sites", "/admin/sites")]
[InlineData("Data Connections", "/admin/data-connections")]
[InlineData("API Keys", "/admin/api-keys")]
[InlineData("LDAP Mappings", "/admin/ldap-mappings")]
public async Task AdminNavLinks_NavigateCorrectly(string linkText, string expectedPath)
{
var page = await _fixture.NewAuthenticatedPageAsync();
await ClickNavAndWait(page, linkText, expectedPath);
}
[Theory]
[InlineData("Templates", "/design/templates")]
[InlineData("Shared Scripts", "/design/shared-scripts")]
[InlineData("External Systems", "/design/external-systems")]
public async Task DesignNavLinks_NavigateCorrectly(string linkText, string expectedPath)
{
var page = await _fixture.NewAuthenticatedPageAsync();
await ClickNavAndWait(page, linkText, expectedPath);
}
[Theory]
[InlineData("Instances", "/deployment/instances")]
[InlineData("Deployments", "/deployment/deployments")]
public async Task DeploymentNavLinks_NavigateCorrectly(string linkText, string expectedPath)
{
var page = await _fixture.NewAuthenticatedPageAsync();
await ClickNavAndWait(page, linkText, expectedPath);
}
[Theory]
[InlineData("Health Dashboard", "/monitoring/health")]
[InlineData("Event Logs", "/monitoring/event-logs")]
[InlineData("Parked Messages", "/monitoring/parked-messages")]
[InlineData("Audit Log", "/monitoring/audit-log")]
public async Task MonitoringNavLinks_NavigateCorrectly(string linkText, string expectedPath)
{
var page = await _fixture.NewAuthenticatedPageAsync();
await ClickNavAndWait(page, linkText, expectedPath);
}
private static async Task ClickNavAndWait(IPage page, string linkText, string expectedPath)
{
await page.Locator($"nav a:has-text('{linkText}')").ClickAsync();
await PlaywrightFixture.WaitForPathAsync(page, expectedPath);
Assert.Contains(expectedPath, page.Url);
}
private static ILocatorAssertions Expect(ILocator locator) =>
Assertions.Expect(locator);
}

View File

@@ -0,0 +1,108 @@
using Microsoft.Playwright;
namespace ScadaLink.CentralUI.PlaywrightTests;
/// <summary>
/// Shared fixture that manages the Playwright browser connection.
/// Creates a single browser connection per test collection, reused across all tests.
/// Requires the Playwright Docker container running at ws://localhost:3000.
/// </summary>
public class PlaywrightFixture : IAsyncLifetime
{
/// <summary>
/// Playwright Server WebSocket endpoint (Docker container on host port 3000).
/// </summary>
private const string PlaywrightWsEndpoint = "ws://localhost:3000";
/// <summary>
/// Central UI base URL as seen from inside the Docker network.
/// The browser runs in the Playwright container, so it uses the Docker hostname.
/// </summary>
public const string BaseUrl = "http://scadalink-traefik";
/// <summary>Test LDAP credentials (multi-role user with Admin + Design + Deployment).</summary>
public const string TestUsername = "multi-role";
public const string TestPassword = "password";
public IPlaywright Playwright { get; private set; } = null!;
public IBrowser Browser { get; private set; } = null!;
public async Task InitializeAsync()
{
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
Browser = await Playwright.Chromium.ConnectAsync(PlaywrightWsEndpoint);
}
public async Task DisposeAsync()
{
await Browser.CloseAsync();
Playwright.Dispose();
}
/// <summary>
/// Create a new browser context and page. Each test gets an isolated session.
/// </summary>
public async Task<IPage> NewPageAsync()
{
var context = await Browser.NewContextAsync();
return await context.NewPageAsync();
}
/// <summary>
/// Create a new page and log in with the test user.
/// Uses JavaScript fetch() to POST to /auth/login from within the browser,
/// which sets the auth cookie in the browser context. Then navigates to the dashboard.
/// </summary>
public async Task<IPage> NewAuthenticatedPageAsync()
{
var page = await NewPageAsync();
// Navigate to the login page first to establish the origin
await page.GotoAsync($"{BaseUrl}/login");
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
// POST to /auth/login via fetch() inside the browser.
// This sets the auth cookie in the browser context automatically.
// Use redirect: 'follow' so the browser follows the 302 and the cookie is stored.
var finalUrl = await page.EvaluateAsync<string>(@"
async () => {
const resp = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=' + encodeURIComponent('" + TestUsername + @"')
+ '&password=' + encodeURIComponent('" + TestPassword + @"'),
redirect: 'follow'
});
return resp.url;
}
");
// The fetch followed the redirect. If it ended on /login, auth failed.
if (finalUrl.Contains("/login"))
{
throw new InvalidOperationException($"Login failed — redirected back to login: {finalUrl}");
}
// Navigate to the dashboard — cookie authenticates us
await page.GotoAsync(BaseUrl);
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
return page;
}
/// <summary>
/// Wait for Blazor enhanced navigation to update the URL path.
/// Blazor Server uses SignalR for client-side navigation (no full page reload),
/// so standard WaitForURLAsync times out. This polls window.location instead.
/// </summary>
public static async Task WaitForPathAsync(IPage page, string path, string? excludePath = null, int timeoutMs = 10000)
{
var js = excludePath != null
? $"() => window.location.pathname.includes('{path}') && !window.location.pathname.includes('{excludePath}')"
: $"() => window.location.pathname.includes('{path}')";
await page.WaitForFunctionAsync(js, null, new() { Timeout = timeoutMs });
}
}
[CollectionDefinition("Playwright")]
public class PlaywrightCollection : ICollectionFixture<PlaywrightFixture>;

View File

@@ -0,0 +1,27 @@
<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="Microsoft.Playwright" Version="1.58.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,94 @@
using Microsoft.Playwright;
namespace ScadaLink.CentralUI.PlaywrightTests;
[Collection("Playwright")]
public class SiteCrudTests
{
private readonly PlaywrightFixture _fixture;
public SiteCrudTests(PlaywrightFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task SitesPage_ShowsTable()
{
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
await Expect(page.Locator("table")).ToBeVisibleAsync();
}
[Fact]
public async Task AddSiteButton_NavigatesToCreatePage()
{
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.ClickAsync("button:has-text('Add Site')");
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites/create");
var inputCount = await page.Locator("input").CountAsync();
Assert.True(inputCount >= 2, $"Expected at least 2 inputs, found {inputCount}");
}
[Fact]
public async Task CreatePage_BackButton_ReturnsToList()
{
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.ClickAsync("button:has-text('Back')");
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
}
[Fact]
public async Task CreatePage_CancelButton_ReturnsToList()
{
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.ClickAsync("button:has-text('Cancel')");
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
}
[Fact]
public async Task CreatePage_HasNodeSubsections()
{
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await Expect(page.Locator("h6:has-text('Node A')")).ToBeVisibleAsync();
await Expect(page.Locator("h6:has-text('Node B')")).ToBeVisibleAsync();
}
[Fact]
public async Task CreatePage_SaveWithoutName_ShowsError()
{
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.ClickAsync("button:has-text('Save')");
// Should stay on create page with validation error
Assert.Contains("/admin/sites/create", page.Url);
await Expect(page.Locator(".text-danger")).ToBeVisibleAsync();
}
private static ILocatorAssertions Expect(ILocator locator) =>
Assertions.Expect(locator);
}

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"maxParallelThreads": 1
}