6 Commits

Author SHA1 Message Date
Joseph Doherty e21791adb0 refactor(ui/monitoring): KPI dashboard, message expand, copy, pagination fix
Dashboard: user-info card demoted; 4 KPI cards (Sites, Data
connections, Templates, API keys) sourced from existing repositories;
3 Quick-action link cards (Health, Audit Log, Templates). Inline
max-width style replaced with Bootstrap utilities.

Health: KPI row condensed to Online / Offline / Sites with active
errors (Total Sites and Total Script Errors dropped). Per-site cards
re-laid out 2-column with each subsection (Data Connections,
Instances & Queues, Errors & Parked Messages) inside Bootstrap
collapse panels collapsed by default. Online / Offline / Primary /
Standby badges paired with shape glyphs (o / * / triangle) plus
aria-label.

EventLogs: filter row wrapped in a Bootstrap collapse toggled by
"Filter options (n active)"; per-row View toggle reveals the full
message in a collapse row; "Keyword" relabeled "Message contains";
all filter inputs gain id+label-for+aria-label; severity badges paired
with a leading glyph; explicit "End of results" terminator on
Load more.

ParkedMessages: Message ID rendered as <code>{first 12}...</code>
plus a clipboard button; per-row View toggle reveals full error;
action buttons get aria-label="{Retry|Discard} message {id}";
in-flight spinner inside the active button.

AuditLog: pagination Next-disabled now uses
_page * _pageSize >= _totalCount via HasMore helper (fixes the
exactly-page-size edge case). Clear filters button added. Entity ID
rendered as code + clipboard button. View/Hide buttons gain
aria-label referencing the entry id. State JSON larger than 1 KB
renders a "View in modal" button instead of the inline overflow.
2026-05-12 03:33:06 -04:00
Joseph Doherty 321ca0bbbf refactor(ui/deployment): live-updates toggle, DebugView guardrails
New shared DiffDialog mirroring ConfirmDialog's API
(ShowAsync(title, before, after)) so live-data pages stop
hand-rolling Bootstrap modal markup.

Topology: <h4> in flex header, aria-labels on Expand/Collapse/Refresh
and the inline rename input, Live-updates toggle (suppresses the 15s
timer when off), instance/area counts moved into a summary alert
above the tree, Stale badge paired with bi-exclamation-triangle icon
+ aria-label, hand-rolled Diff modal replaced with <DiffDialog @ref>.

Deployments: pause/resume auto-refresh button replaces the static
"Auto-refresh: 10s" text; summary cards switch to
col-lg-3 col-md-6 col-12; InProgress spinner gets role="status" +
aria-label; failed rows pick up a bi-x-circle icon next to the
Status badge; Deployment ID + Revision folded into one
{id}@{revision[..8]} cell; inline Error column collapses behind a
per-row "View error" toggle; bare empty-state text upgraded to the
centered muted block.

DebugView: status-strip card at the top showing instance / connection
state / last snapshot timestamp plus a "Start fresh" button when the
page auto-reconnected from localStorage. Per-table filter input,
scroll-lock toggle, Clear button, and a 200-row queue-style cap.
<tbody> elements gain aria-live="polite" aria-atomic="false" for
screen-reader announcements. Quality and Alarm-State badges get
aria-labels; timestamps display HH:mm:ss with full ms in a hover
tooltip. Auto-reconnect surfaces a toast with autoDismissMs: 8000.
2026-05-12 03:32:53 -04:00
Joseph Doherty b6e2ec8a50 refactor(ui/design): card grid, SMTP split, TemplateEdit vertical-stack
Templates: <h4> in flex header, Expand/Collapse moved into a Bulk
actions dropdown, hover-visible kebab on tree nodes with aria-labels.
TreeView CSS gets a .tv-kebab opacity-on-hover utility.

TemplateCreate: form-control (not -sm) for primary inputs; accessible
Back button.

TemplateEdit: Properties card vertical-stacked with Save at the
bottom-right and Parent rendered as readonly plaintext. Add-member
forms (Attributes, Alarms, Scripts, Compositions) reflowed from
horizontal row g-2 align-items-end into cards with stacked col-12
inputs (Scripts gets rows=10). Lock/Unlock badges show full words.
Per-row Delete moved into a kebab dropdown. Tab nav gains
role="tablist" / role="tab" / aria-selected / aria-controls and panels
get role="tabpanel". Validation entries get consistent strong-and-
muted styling.

SharedScripts: migrated from table to card grid (col-lg-6) matching
Sites; cards show code preview + param/return badges + Edit + kebab.
Search filter, empty state CTA, @key.

SharedScriptForm: small ?-icon tooltips next to Parameters and Return
Definition labels.

ExternalSystems: SMTP split out to its own page; remaining tabs (
External Systems, DB Connections, Notification Lists, API Methods,
API Keys) unified as card grids with per-tab search + empty-state CTA.
Tab nav gets full ARIA instrumentation. Header gains a link to the
new SMTP page.

New page SmtpConfiguration.razor at /design/smtp: vertical-stacked
form using the existing Credentials field on the entity.

ExternalSystemForm: AuthConfig placeholder updates based on the
selected AuthType (None / ApiKey / BasicAuth).

DbConnectionForm: form-text below Connection String noting that the
value is stored in plain text and is admin-only.

ApiMethodForm: Script textarea rows=10; JSON example placeholders
for Params and Returns.

NotificationListForm: form-control sizing on Name/email inputs;
thead.table-dark -> table-light on the recipients table.
2026-05-12 03:32:39 -04:00
Joseph Doherty da2c0d714e refactor(ui/admin): card grid, search, kebab; LDAP scope-rule chips
LdapMappings: flex header, search filter, per-row Edit + kebab Delete,
@key, dropped Site-Scope-Rules cell in favor of a {n rule(s)} badge.

LdapMappingForm: two stacked cards (Mapping then Site Scope Rules);
scope rules render as removable chips with an inline "Add scope rule"
form; create-mode disables the scope card with an explainer; role
select gets form-text help.

DataConnections: <h4> in flex header, Bulk actions dropdown holding
Expand/Collapse, hover-visible kebab on tree nodes mirroring the
right-click context menu, aria-labels, "No connections match the
filter." inline empty state.

DataConnectionForm: Site rendered as readonly plaintext + lock-after-
creation note in edit mode; parallel Primary endpoint / Backup endpoint
headings; "Optional" badge on Backup when null; form-text on
FailoverRetryCount.

ApiKeys: search filter, Status column dropped (state now lives in the
kebab menu label "Disable"/"Enable"), Edit + kebab actions, @key,
aria-labels.

ApiKeyForm: nested card removed; fixed-text Back header; real
clipboard copy via IJSRuntime + toast confirmation.

Test selector fix in DataConnectionFormTests for the new Site
readonly-plaintext rendering.
2026-05-12 03:32:17 -04:00
Joseph Doherty f7b10f2ff7 refactor(ui/shared): scroll-lock, escape, aria-live, responsive sidebar
ConfirmDialog locks body scroll via IJSRuntime + Bootstrap's
modal-open class on show, restores on hide. Escape key now closes
the dialog; default ConfirmButtonClass flipped from btn-danger to
btn-primary so non-destructive confirms aren't red. Destructive
callsites (Delete, Discard) get explicit ConfirmButtonClass="btn-danger".

ToastNotification adds aria-live="polite" + aria-atomic="true" on the
container and an optional autoDismissMs parameter on every Show* method.

LoadingSpinner text-muted -> text-secondary for contrast.

DataTable gains a clear (x) button on the search input and applies
disabled / aria-disabled directly to the pagination buttons.

NewFolderDialog splits backdrop and modal markup to match ConfirmDialog.

NavMenu wraps the nav list in an overflow-y scroll container so the
username/sign-out footer stays anchored, and section headers convert
from <li> to <div role="presentation">.

MainLayout adds a hamburger toggle for <lg viewports; sidebar collapses
via Bootstrap collapse data attributes.

App.razor extracts inline <style> block to a shared site.css; adds a
left-border accent on the active nav link; switches the reconnect
modal to modal-dialog-centered.

Login uses d-flex / min-vh-100 centering. NotAuthorizedView gets the
same centered layout plus the ScadaLink brand heading.

Sites.razor: only the new ConfirmButtonClass="btn-danger" follow-up.
2026-05-12 03:32:07 -04:00
Joseph Doherty ff5f5a10ef docs(ui): UI audit findings (2026-05-12)
Audit of every page in CentralUI against the Sites.razor card-grid
pattern, the no-third-party-UI-libs constraint, and accessibility
basics. Findings + per-page severity + suggested implementation
order live in docs/plans/. Implementation follows in subsequent commits.
2026-05-12 03:31:54 -04:00
41 changed files with 3509 additions and 1150 deletions
+554
View File
@@ -0,0 +1,554 @@
# ScadaLink Central UI — Design & UX Audit
**Date:** 2026-05-12
**Branch at audit time:** `feature/templates-folder-hierarchy` (after `Sites.razor` redesign, commit `0805e18`)
**Scope:** All Razor pages, layout, and shared components in `src/ScadaLink.CentralUI`.
**Reference pattern:** `src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor` — 2-column responsive card grid, header flex row, kebab menus, search filter, Bootstrap collapse for noisy details, `@key=` on iterated cards, "No X match the filter." and empty-state CTAs.
## Constraints (recap)
- Blazor Server + Bootstrap 5 only. **No third-party component frameworks** (no MudBlazor / Radzen / Blazorise / Syncfusion).
- Clean, corporate, internal-use aesthetic. Not flashy.
- Form pages: vertical stacking; read-only fields first; subsections stacked; buttons at bottom.
- Accessibility: aria-labels on icon buttons; labels paired with inputs; semantic headings; never use color as the only state cue.
---
## Severity summary
| Severity | Count | Pages |
|---|---|---|
| **High** | 7 | LdapMappingForm · DataConnections (header/a11y) · SharedScripts · ExternalSystems · TemplateEdit · DebugView · EventLogs |
| **Medium** | 11 | LdapMappings · ApiKeys · DataConnections · DataConnectionForm · ApiKeyForm (partial) · Templates · Topology · Deployments · Dashboard · Health · ParkedMessages · AuditLog · MainLayout / NavMenu · ConfirmDialog · Toast · global CSS |
| **Low** | 7+ | Most form pages (TemplateCreate, ExternalSystemForm, SharedScriptForm, DbConnectionForm, ApiMethodForm, NotificationListForm) · Login error feedback · NotAuthorizedView · LoadingSpinner contrast · DataTable clear-button |
**Suggested implementation order** (high impact / low risk first):
1. **Shared shell fixes** (ConfirmDialog scroll-lock + Escape + default button color, Toast `aria-live` + custom delay, NavMenu scroll container, login vertical centering) — these unblock everything else and are mostly small.
2. **List-page pattern roll-out:** apply the Sites.razor card grid + search + kebab template to LdapMappings, ApiKeys, SharedScripts. These are mechanical.
3. **DebugView guardrails:** scroll-lock, max-row cap, `aria-live`, filter — this is high-severity and isolated.
4. **EventLogs:** message expand, pagination clarity, filter accessibility.
5. **ExternalSystems + TemplateEdit refactors** — biggest scope, leave for last because they need design discussion before implementation.
---
## Cross-cutting findings (apply to many pages)
These show up everywhere. Fix at the pattern level first, then tour every page once to apply:
1. **`<h4>` page title in a flex header.** Sites.razor sets the standard at line 16. Currently Templates (`<h6>`), Topology (`<h6>`), Dashboard (`<h3>`), and most form pages mix levels. Adopt `<h4 class="mb-0">` inside `d-flex justify-content-between align-items-center mb-3`.
2. **Search input above the list.** `max-width: 320px`, bound to `_search` with `@bind:event="oninput"`, plus the "No X match the filter." inline message. Missing on: LdapMappings, ApiKeys, SharedScripts, EventLogs, ParkedMessages (per-site only), AuditLog.
3. **Kebab (⋮) menu for less-frequent actions.** Edit stays as a primary button; Delete/Disable/Deploy move into the dropdown. Missing on: LdapMappings, ApiKeys, SharedScripts, TemplateEdit member rows, ParkedMessages.
4. **`@key="entity.Id"` on iterated rows / cards.** Prevents Bootstrap collapse state leaks (the bug caught in smoke on Sites). Apply anywhere `@foreach` renders elements with Bootstrap stateful classes (`show`, `collapsed`, `active`).
5. **State badges must not rely on color alone.** Add either icon + text or `aria-label="State: …"`. Affected: Health node Online/Offline, Topology Stale, Deployments row colors, DebugView Quality / Alarm State, AuditLog action badges.
6. **`TimestampDisplay` component consistency.** EventLogs / ParkedMessages / AuditLog use it; Health and DebugView format inline. Pick the component, give it a single rendering of "HH:mm:ss UTC" or relative+absolute, retrofit everywhere.
7. **Empty-state CTA when count is 0.** Sites.razor lines 53-60 are the template. Missing on: SharedScripts, Templates (tree), ExternalSystems tabs, ParkedMessages, AuditLog.
8. **`aria-label` on icon-only buttons** (`⋮`, `📋`, copy, expand/collapse). Almost universally missing today.
9. **Truncate-and-expand pattern.** AuditLog has the cleanest pattern (`View` toggle for state JSON). Apply to long message strings (EventLogs, ParkedMessages, Deployments errors) instead of mid-string CSS truncation.
---
## Admin section
### LdapMappings.razor — `/admin/ldap-mappings` — **Medium**
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor`
**What it does:** Lists LDAP group → role mappings with inline Edit/Delete and Site Scope hints.
**Issues**
1. *Consistency:* Header (line 12) lacks the Sites flex layout + Bulk actions dropdown next to the primary Add button.
2. *Density:* 5-column table; "Site Scope Rules" cell jams multiple badges into a narrow column.
3. *Consistency:* No search filter. Sites uses one at lines 67-69.
4. *Consistency:* Edit + Delete rendered as twin buttons in the row; Sites uses kebab.
5. *Other:* "Site Scope Rules" preview in the row + the "(manage on edit page)" hint creates a confusing duality — the list page promises something it can't deliver.
**Recommendations**
1. Add header flex layout + search input.
2. Replace Edit/Delete pair with `Edit` button + `⋮` dropdown containing Delete.
3. Either drop the Site Scope column from the list entirely (show a `n rule(s)` badge instead) or expand it into a collapse panel on the row.
4. If keeping table layout, add `@key="m.Id"`.
---
### LdapMappingForm.razor — `/admin/ldap-mappings/create` and `/{Id}/edit` — **High**
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor`
**What it does:** Create/edit a single mapping, plus a secondary panel for Site Scope Rules in edit mode.
**Issues**
1. *Form-layout:* Two distinct sub-forms on one page (mapping basics + scope rules) with no visual separation. Scope rules only become editable after Save, but the UI doesn't communicate that workflow.
2. *Hierarchy:* Both sections use `<h6>` inside `card-title`; no primary/secondary hierarchy.
3. *Form-layout:* Scope-rule entry uses a nested table inside the card; visually heavy.
4. *Accessibility:* Role `<select>` has no `aria-describedby` / help text explaining why "Deployment" surfaces the scope rules section.
**Recommendations**
1. Restructure: top card "Mapping" stacked vertically (Name, LDAP Group, Role, [Save]); below it, a card "Site Scope Rules" that's disabled-with-explanation in create mode and editable in edit mode.
2. Replace the nested scope-rule table with a tag-style chip list: each scope rule renders as a removable chip; an inline "Add scope rule" form sits below.
3. Add `form-text` under Role: "Deployment role: configure site scope below after saving."
---
### DataConnections.razor — `/admin/connections` — **High** for header / a11y, **Medium** overall
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor`
**What it does:** Treeview of sites and their data connections with context menu CRUD.
**Issues**
1. *Hierarchy:* Page title is `<h6>` (line 24). Promote to `<h4>` with flex header to match Sites.
2. *Consistency:* Inline `btn-group` with Refresh / Expand / Collapse buttons next to search; visually busy. Sites uses Bulk actions dropdown + Add button only.
3. *Accessibility:* Tree node kebab toggles lack `aria-label="More actions for {name}"`.
4. *Other:* Right-click context menu has no visible hover affordance — easy to miss.
5. *Other:* When search returns no matches, the tree silently collapses; no empty-state message.
**Recommendations**
1. Promote heading, adopt flex header. Move Expand/Collapse into a Bulk actions dropdown; drop Refresh (navigation reload covers it).
2. Add visible kebab on tree-node hover so the context menu is discoverable.
3. Add `aria-label` to every kebab toggle (interpolate the node name).
4. Add "No connections match the filter." inline when search clears the tree.
---
### DataConnectionForm.razor — `/admin/connections/create` and `/{Id}/edit` — **Medium**
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor`
**What it does:** Create/edit a connection with primary + optional backup endpoint editors (OPC UA only today).
**Issues**
1. *Form-layout:* Site field is disabled in edit mode but rendered as a disabled `<select>` with no read-only styling cue beyond gray.
2. *Hierarchy:* "Backup endpoint" `<h6>` uses `border-bottom`; primary endpoint has no parallel heading. Hierarchy is one-sided.
3. *Density:* "Add Backup Endpoint" button buried inside the card with no signposting that backup is optional.
4. *Accessibility:* No `form-text` on Primary Endpoint / Site / failover knobs.
**Recommendations**
1. Use `<input class="form-control-plaintext" readonly>` for the Site field in edit mode and add a small explanatory line ("Site is locked after creation").
2. Mirror the heading pattern: both Primary and Backup get `<h6>` headers; Backup also gets a clear "Optional" badge.
3. Add `form-text` help under each tuning knob (PublishingIntervalMs, SamplingIntervalMs, FailoverRetryCount, etc.).
---
### ApiKeys.razor — `/admin/api-keys` — **Medium**
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor`
**What it does:** Lists API keys with Edit / Disable-Enable / Delete actions; masked key value.
**Issues**
1. *Consistency:* No search filter.
2. *Density:* 5-column table; Status column is redundant with the Disable/Enable button.
3. *Consistency:* Three buttons in the Actions cell (Edit / Disable / Delete) — should be Edit + kebab.
4. *Other:* No `@key="k.Id"` on rows.
**Recommendations**
1. Add search filter and `@key`.
2. Drop the Status column; let the kebab item read "Disable" or "Enable" depending on state.
3. Either keep the table and adopt the kebab pattern, or move to the Sites card grid — for ~5 keys per environment the table is fine; for 50+ the card grid would scan better.
---
### ApiKeyForm.razor — `/admin/api-keys/create` and `/{Id}/edit` — **Low**
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor`
**What it does:** Create an API key (showing the secret once) or rename an existing one.
**Issues**
1. *Form-layout:* Header has conditional "Back to API Keys" vs "Back" text.
2. *Other:* Copy button on the one-shot secret reveal is wired to a comment / no-op.
3. *Density:* Form is one field but wrapped in card-inside-card.
**Recommendations**
1. Fixed header: `← Back · Add / Edit API Key`.
2. Implement the copy via `IJSRuntime` + `navigator.clipboard.writeText` (mirror Sites.razor's `CopyAsync`).
3. Remove redundant card nesting; render the input + buttons directly in `<div class="container-fluid mt-3">`.
---
## Design section
Files discovered:
```
Components/Pages/Design/Templates.razor @page /design/templates
Components/Pages/Design/TemplateCreate.razor @page /design/templates/create
Components/Pages/Design/TemplateEdit.razor @page /design/templates/{Id:int}
Components/Pages/Design/SharedScripts.razor @page /design/shared-scripts
Components/Pages/Design/SharedScriptForm.razor @page /design/shared-scripts/{create|edit}
Components/Pages/Design/ExternalSystems.razor @page /design/external-systems
Components/Pages/Design/ExternalSystemForm.razor @page /design/external-systems/{create|edit}
Components/Pages/Design/DbConnectionForm.razor @page /design/db-connections/{create|edit}
Components/Pages/Design/ApiMethodForm.razor @page /design/api-methods/{create|edit}
Components/Pages/Design/NotificationListForm.razor @page /design/notification-lists/{create|edit}
```
### Templates.razor — **Medium**
**What it does:** Folder-tree view of templates with context-menu CRUD.
**Issues**
1. *Hierarchy:* Page title is `<h6>` (line 53) — should be `<h4>` in flex header.
2. *Consistency:* `btn-group-sm` of outline buttons for Expand/Collapse — push these into a Bulk actions dropdown.
3. *Accessibility:* Context-menu buttons (lines 271-288) lack `aria-label`.
4. *Density:* Treeview height is hardcoded `calc(100vh - 160px)` with no scroll affordance.
5. *Other:* No breadcrumb when an edit page navigates away from the tree context.
**Recommendations**
1. Promote heading, adopt flex header pattern.
2. Move Expand/Collapse into the Bulk actions dropdown.
3. Add aria-labels on every context-menu button (interpolate node name).
4. Add a top breadcrumb on TemplateEdit so users know which folder they're editing inside.
---
### SharedScripts.razor — **High**
**What it does:** Table of shared scripts with name, code preview, parameters, returns.
**Issues**
1. *Consistency:* Table instead of card grid — and code preview is rendered as truncated monospace inline, which is unreadable beyond ~40 chars.
2. *Density:* 6 columns (ID, Name, Code preview, Parameters, Returns, Actions). ID is internal-only.
3. *Consistency:* No search, no empty-state CTA.
4. *Accessibility:* Truncated code preview has no `title=` tooltip.
**Recommendations**
1. Migrate to a card grid (col-lg-6) mirroring Sites: title = Name, body = small code snippet (first 80 chars) + parameter/return counts as chips, footer = Edit + ⋮ Delete.
2. Drop ID column entirely.
3. Add search by name + code substring.
4. Add "No shared scripts configured. Create your first script." CTA.
---
### ExternalSystems.razor — **High**
**What it does:** Tabbed hub for External Systems, DB Connections, Notification Lists, Inbound API Methods, SMTP Config, API Keys.
**Issues**
1. *Density:* Six subsections on one page with no search per tab; SMTP form crams 6+ inputs in one `row g-2 align-items-end` flex row.
2. *Consistency:* Tabs use mixed renderings — External Systems / DB / API Methods use tables; Notification Lists and SMTP use cards. Same-level data, inconsistent shape.
3. *Form-layout:* SMTP form violates the vertical-stacking rule.
4. *Hierarchy:* Subsection headings are `<h6>` with badge counts — heading level is too small.
5. *Accessibility:* Tab buttons lack `role="tab"` / `aria-selected`.
6. *Other:* No per-tab empty state.
**Recommendations**
1. Split SMTP off as a standalone `/admin/smtp` (it's a single-row global config, not list data).
2. Unify all tabs on the same card-grid pattern.
3. Reformat the remaining SMTP page to vertical-stacked fields per `feedback_form_layout`.
4. Add `role="tablist"` / `role="tab"` / `aria-selected` and `aria-controls` on the tab nav.
5. Add per-tab search + empty-state CTAs.
---
### TemplateEdit.razor — **High**
**What it does:** Edit a template's properties plus Attributes / Alarms / Scripts / Compositions in tabs.
**Issues**
1. *Density:* Template Properties card uses a 4-column row; Parent Template renders as `form-control-plaintext` next to live inputs, then a Save button at col-md-2. Save ends up mid-row instead of at the bottom.
2. *Form-layout:* "Add Attribute / Alarm / Script" inline forms use `row g-2 align-items-end` — the Scripts row stuffs 4 inputs + a textarea horizontally.
3. *Consistency:* Card headers inconsistent — some "card-title" h6 inside `card-body`, some bare h6 above a section.
4. *Hierarchy:* Validation result alerts mix strong-heading + bare `<li>` items.
5. *Accessibility:* Lock-state badges render as cryptic single letters "L"/"U" with no `aria-label`. Tabs lack `role="tab"` / `aria-selected`.
6. *Other:* Per-row Delete buttons scattered; many tables.
**Recommendations**
1. Reflow Template Properties to vertical-stack (col-12 each), put Save at the bottom following the form-layout rule.
2. Reformat add-forms into a card with stacked col-12 inputs; Scripts gets a full-width Monaco-ish textarea (rows≥10) below the metadata fields.
3. Replace L/U badges with full text + `aria-label`: `<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>`.
4. Per-row kebab menu replacing Delete (with future Duplicate / Move options).
5. Add `role`/`aria-selected` to all tab buttons.
---
### TemplateCreate.razor — **Low**
1. Use `form-control` not `form-control-sm` for the primary Name field.
2. Replace the `&larr;` arrow on the Back button with text `← Back` and add `aria-label="Back to Templates"`.
---
### ExternalSystemForm.razor — **Low**
1. Auth Config field: add a JSON example placeholder matching the chosen AuthType.
---
### SharedScriptForm.razor — **Low**
1. Add a small `bi-question-circle` icon next to Parameters / Return Definition linking to a tooltip with schema reference.
2. When syntax check fails, surface line/column position in the error message.
---
### DbConnectionForm.razor — **Low**
1. Add reassurance text under Connection String: "Stored encrypted; not displayed after save." (only if the back end actually does this; otherwise drop the claim.)
---
### ApiMethodForm.razor — **Low**
1. Script textarea bumped from rows=5 to rows≥10.
2. Add JSON example placeholders for Params and Returns.
---
### NotificationListForm.razor — **Low**
1. Resize the Name input to `form-control` (not `form-control-sm`).
2. Recipients `<thead class="table-dark">``table-light` for consistency.
---
## Deployment section
Files discovered:
```
Components/Pages/Deployment/Topology.razor @page /deployment/topology (and /deployment/instances)
Components/Pages/Deployment/Deployments.razor @page /deployment/deployments
Components/Pages/Deployment/DebugView.razor @page /deployment/debug-view
(+ InstanceCreate, InstanceConfigure, CreateAreaDialog, MoveAreaDialog, MoveInstanceDialog)
```
### Topology.razor — **Medium**
1. *Hierarchy:* `<h6>` page title (line 63) — promote to `<h4>` in flex header.
2. *Accessibility:* Expand / Collapse / Refresh / Search / tree-kebab buttons all lack `aria-label`. Inline rename input has no label.
3. *Live-data UX:* No "pause live updates" toggle; tree can repaint while user is renaming or moving a node.
4. *Density:* Instance counts footer text — could be a summary card above the tree.
5. *State cues:* Stale badge is yellow-only; pair with text or icon.
6. *Consistency:* Diff modal is hand-rolled Bootstrap modal markup — should be a reusable `<DiffDialog>` mirroring `<ConfirmDialog>`.
**Recommendations**
1. Promote heading, adopt flex header.
2. Add aria-labels everywhere (treat the kebab and rename input as the priority).
3. Add a "Live updates: on/off" toggle button next to Refresh; pause auto-refresh during edits.
4. Move counts to a small summary card above the tree.
5. Pair Stale badge with `aria-label="State: Stale"` and a 🟡 dot or "STALE" text.
6. Extract `<DiffDialog>` into `Components/Shared/`.
---
### Deployments.razor — **Medium**
1. *Density:* 8 columns (Deployment ID, Instance, Status, Deployed By, Started, Completed, Revision, Error). Both Deployment ID and Revision are truncated hashes; Error can be a stack trace.
2. *Live-data UX:* Auto-refresh runs every 10s with no pause control — if a user is reading an error message, the row can swap underneath them.
3. *Consistency:* Summary cards use `col-md-3` only (no `col-sm-6` fallback for tablet); cards are styled differently from Sites.
4. *Accessibility:* Spinner inside the status badge has no `role="status"` / `aria-label`. "Auto-refresh: 10s" text is decorative, not a control.
5. *State cues:* Row colors (`table-danger`, `table-info`) without an icon or stripe.
6. *Other:* Empty state is a single line of text.
**Recommendations**
1. Collapse Error column into a `View error` button that pops a `<DiffDialog>`-style modal (or inline collapse row).
2. Add `Live updates: 10s [pause]` toggle.
3. Make summary cards `col-lg-3 col-md-6 col-12`.
4. Add aria-labels on the spinner and the toggle.
5. Add `border-start border-3 border-danger` or icon to failed rows.
6. Either fold Deployment ID + Revision into one cell or hide one behind the detail modal.
---
### DebugView.razor — **High**
1. *Live-data UX:* No scroll-lock on the streaming tables. Auto-scroll behavior is implicit. No max-row cap → tab can balloon in memory.
2. *Live-data UX:* Timestamps shown to milliseconds; noisy at sustained update rates.
3. *Live-data UX:* No stream filter (e.g., "only alarms with state=Active") — once subscribed, you watch everything.
4. *Accessibility:* Quality / Alarm State badges are color-only. No `aria-live="polite"` on the streaming table bodies.
5. *Consistency:* "Snapshot received at …" is a tiny muted footer; should be a header-level status strip.
6. *UX risk:* Page persists session in `localStorage` and auto-reconnects on refresh, with no user-visible notice.
**Recommendations**
1. Add per-table `🔒 Lock scroll` toggle.
2. Cap rows at e.g. 200; add a `Clear` button.
3. Add per-table filter input.
4. Display timestamps as `HH:mm:ss` by default; `.fff` only inside an "Expanded row" view.
5. Add `aria-live="polite" aria-atomic="false"` on the streaming table bodies.
6. Pair every Quality and Alarm State badge with `aria-label`.
7. Replace the snapshot footer with a status strip: instance · connection state · last snapshot time.
8. On auto-reconnect, toast "Auto-reconnected to {instance}" with a `Start fresh` button.
---
## Monitoring section + Dashboard
Files discovered:
```
Components/Pages/Dashboard.razor @page /
Components/Pages/Monitoring/Health.razor @page /monitoring/health
Components/Pages/Monitoring/EventLogs.razor @page /monitoring/event-logs
Components/Pages/Monitoring/ParkedMessages.razor @page /monitoring/parked-messages
Components/Pages/Monitoring/AuditLog.razor @page /monitoring/audit-log
```
### Dashboard.razor — **Medium**
1. *Dashboard UX:* It is currently just a user-info card. For a central SCADA console the landing page should show system KPIs first (sites online/offline, errors, queue depths, parked-message count) — the things you'd want to see in <5 seconds.
2. *Hierarchy:* `<h3>` heading; rest of the site is `<h4>`.
3. *Consistency:* Inline `style="max-width:500px"` instead of Bootstrap utilities.
**Recommendations**
1. Repurpose as a "Glance" page: KPI cards across the top (Sites, Errors, Parked Messages, Latest deployments status), a sites-by-health small list, recent audit events.
2. Move the user-info card to a secondary panel or drop it (it's already in the top-right of the layout).
3. `<h3>``<h4>` for site-wide consistency, replace inline styles with utility classes.
---
### Health.razor — **Medium**
1. *KPI choices:* Sites Online + Sites Offline + Total Sites is redundant; Total Script Errors is global and not actionable. Promote "Sites with active errors" / "Cluster degraded" instead.
2. *Hierarchy:* Header is `<h4>` left-aligned with no flex header; doesn't match Sites.
3. *Density:* Per-site cards use a 4-column inner grid that breaks on narrow viewports.
4. *Time format:* `HH:mm:ss` only, no timezone, no relative.
5. *State cues:* Online/Offline / Primary/Standby badges are color-only.
**Recommendations**
1. Replace "Total Sites" KPI with "Sites with active errors" or "Cluster health %".
2. Adopt flex header layout.
3. Reduce per-site card to 2 columns (col-md-6) or wrap each subsection in a collapse à la Sites.razor "Cluster nodes".
4. Use `TimestampDisplay` with UTC suffix; consider adding a relative time hint ("3 minutes ago").
5. Add `aria-label` and an icon to every Online/Offline/Primary/Standby badge.
---
### EventLogs.razor — **High**
1. *Density:* "Message" column truncates long error strings mid-string with no expand.
2. *Pagination:* "Load more" + continuation token, no total count shown.
3. *Filter affordance:* 7 filter inputs in one row; "Keyword" label is vague.
4. *Accessibility:* Labels are not linked to inputs via `for`/`id`; row colors are the primary severity cue.
5. *Time:* Uses `<TimestampDisplay>` — confirm it standardises with the other log pages.
**Recommendations**
1. Apply AuditLog's `View` / `Hide` toggle pattern for the Message cell.
2. Switch to numeric pagination ("Page X of Y, N total") or surface a total count next to the Load More button.
3. Move the filter row into a Bootstrap collapse with label `Filter options (n active)`.
4. Add `id`/`for` pairings, `aria-label`s, and pair the row color with an icon stripe.
5. Standardise on `TimestampDisplay` across all log pages.
---
### ParkedMessages.razor — **Medium**
1. *Density:* Message ID is truncated to 12 chars with no copy or expand affordance.
2. *Density:* Error message field can be long; no expand.
3. *Accessibility:* Retry / Discard buttons have `title=` only, no `aria-label`.
4. *State:* No spinner / disabled affordance while a Retry is in flight.
**Recommendations**
1. Render Message ID as a `<code>` with a `📋 Copy` button or expand row showing the full ID + error.
2. Apply AuditLog's expand toggle for error messages.
3. Add `aria-label="Retry message {id}"` and `aria-label="Discard message {id}"`.
4. Replace each action button's normal/disabled state with a small spinner during the action.
---
### AuditLog.razor — **Medium**
1. *Pagination bug:* `Next` is disabled when `_entries.Count < _pageSize`; this misfires when the last page has exactly `_pageSize` rows (will show enabled Next that returns empty).
2. *Filter affordance:* 5 filter inputs in one row; no `Clear filters` button.
3. *Density:* Entity ID is a full GUID with no copy / expand.
4. *State expansion:* JSON detail has `max-height: 200px` with no "expand to full size" affordance.
5. *Accessibility:* `View`/`Hide` button has no `aria-label`.
**Recommendations**
1. Fix the pagination logic: rely on a "has more" flag from the API, not a length compare.
2. Add a `Clear filters` button next to the filter row.
3. Add a copy button or expand-on-click for Entity ID.
4. Make the JSON detail panel resizable, or open in a `<DiffDialog>`-style modal when content exceeds 1 KB.
5. Add `aria-label` to the toggle (interpolate entry id).
---
## Layout, shared components, global CSS
### MainLayout.razor / NavMenu.razor / App.razor
**Issues**
1. *Responsive:* Sidebar is fixed `min-width: 220px / max-width: 220px` in `App.razor` lines 13-14. No `d-none d-lg-flex` or hamburger toggle for narrow viewports. **High.**
2. *Scrolling:* `<ul class="nav flex-column flex-grow-1">` has no overflow boundary. If role-driven nav becomes long enough, the footer (username + Sign Out) will scroll off-screen. **Medium.**
3. *Semantics:* Section headers (Admin, Design, …) render as bare `<li class="nav-section-header">` — not focusable / not semantic. **Medium.**
4. *Active state:* Active blue (#0d6efd) and hover gray (#343a40) are similar enough to confuse — pair active with a left border or underline. **Low.**
**Recommendations**
1. Wrap the sidebar in `d-none d-lg-flex` + add a hamburger button in the top bar for `<lg` viewports. Replace fixed widths with `flex-basis: 220px` and let it collapse off-canvas on mobile.
2. Wrap `<ul>` in `<div style="overflow-y:auto; flex:1 1 auto;">` so the footer is always anchored.
3. Convert section headers to `<li role="presentation"><span class="nav-section-header">Admin</span></li>` or just `<div role="separator" aria-label="Admin section">`.
4. Add `border-left: 3px solid var(--bs-primary)` to `.nav-link.active`.
---
### Login.razor — **Medium** / **Low**
**Issues**
1. *Centering:* `margin-top: 10vh;` on the container — on short viewports the card pushes below the fold. **Medium.**
2. *Validation:* No client-side validation feedback for empty fields; only server-side via `?error=` query param. **Low.**
**Recommendations**
1. Wrap in `<div class="d-flex align-items-center justify-content-center min-vh-100">` for true vertical centering.
2. Add HTML5 `required` and `:invalid` styling; keep the server-side error banner for actual auth failures.
---
### NotAuthorizedView.razor — **Low**
1. Wrap in the same centered layout as Login, with the "ScadaLink" brand heading on top — currently feels orphaned.
---
### ToastNotification.razor — **Medium**
**Issues**
1. *z-index:* Toasts are at `z-index: 1090`; Bootstrap modal backdrop defaults to 1040 and the modal element itself to 1055. Currently OK, but ConfirmDialog markup doesn't set explicit z-index on the modal element — document the hierarchy or set explicit values.
2. *Auto-dismiss:* Hardcoded 5 s. No way to extend for important messages.
3. *Accessibility:* `role="alert"` is set but `aria-live="polite"` / `aria-atomic="true"` are missing.
**Recommendations**
1. Document the z-index ladder in a comment at the top of the component; set explicit z-index in `ConfirmDialog` too.
2. Add `[Parameter] public int AutoDismissMs { get; set; } = 6000;`.
3. Add `aria-live="polite" aria-atomic="true"` to the container.
---
### ConfirmDialog.razor — **High** / **Medium**
**Issues**
1. *Scroll:* Backdrop doesn't add `overflow: hidden` to `<body>` — the page behind scrolls under the dialog. **High.**
2. *Keyboard:* No `Escape`-to-close handler. No focus trap. **Medium.**
3. *Defaults:* `ConfirmButtonClass` defaults to `btn-danger` — wrong for non-destructive confirms. **Medium.**
**Recommendations**
1. On `ShowAsync`, JS-interop add `overflow:hidden` to `body`; remove on close.
2. Add `@onkeydown="..."` for Escape → Cancel; on show, focus the cancel button (or the safer button) and on close return focus to the trigger.
3. Default `ConfirmButtonClass` to `btn-primary`; explicit `btn-danger` on destructive call sites only.
---
### LoadingSpinner.razor — **Low**
1. `text-muted` on a light background may not meet 4.5:1. Switch to `text-secondary`.
---
### DataTable.razor — **Low**
1. Search input has no clear (✕) button.
2. Pagination disabled state is on the parent `<li>` not the button — apply `disabled` directly + `aria-disabled="true"`.
---
### NewFolderDialog.razor — **Low**
1. Uses combined modal + inline background style instead of a separate `<div class="modal-backdrop fade show">` like ConfirmDialog. Refactor to match.
---
### TreeView.razor / TreeView.razor.css
1. Reliance on `var(--bs-*)` is good; no change.
2. Same a11y caveats as Topology — hover/focus visuals must reach kebab toggles.
---
### Global CSS — **Medium**
**Issues**
1. *Inline:* ~60 lines of `<style>` are inline in `App.razor` instead of in a `wwwroot/css/site.css` file.
2. *Theming:* Sidebar uses hardcoded hex colors (#212529, #343a40, #adb5bd, #fff); blocks any future light-mode / brand variation work.
3. *Reconnect modal:* Uses ad-hoc flex centering; could just be `.modal-dialog-centered`.
**Recommendations**
1. Move inline styles to `wwwroot/css/site.css` and link in `App.razor`.
2. Replace hex with `var(--bs-dark)` / `var(--bs-light)` etc.
3. Use Bootstrap's `.modal-dialog-centered` for the reconnect overlay.
---
## Cross-cutting strategic recommendations
These are bigger investments that pay back across many pages:
1. **Dialog/Modal service.** A single `IDialogService` that owns z-index stacking, body scroll lock, focus trap, Escape-to-close. Replace per-component ad-hoc backdrops. Fixes ConfirmDialog scroll-lock, focus-trap, and z-index collisions in one stroke; also unblocks the planned `<DiffDialog>` for Topology and the error-detail modal for Deployments.
2. **Accessibility pass.** Adopt a single rule: every icon-only button has `aria-label`; every state badge is colour + text + icon; every form input has linked label and optional `aria-describedby`. Most pages need ~5 minutes of edits to comply.
3. **Design tokens via CSS variables.** Pull the sidebar palette + the few custom colors into `:root` custom properties. Adopt Bootstrap's CSS variables (`--bs-*`) for everything else. Unblocks light/dark mode and any future rebrand.
4. **Pagination + filter component.** EventLogs / ParkedMessages / AuditLog / Deployments all roll their own. Extract one `<PagedTable TItem>` or at least a `<Paginator>` that takes (page, pageSize, total) and emits standard events.
5. **`TimestampDisplay` audit.** Make sure every consumer goes through it; standardise on UTC display + tooltip with relative time. Eliminate inline `.ToString("HH:mm:ss")` calls.
6. **One reference page for list patterns.** Use `Sites.razor` as the reference; add a comment at the top of it pointing future implementers at it (or extract its skeleton into a snippet under `docs/`).
---
## Out of scope / decisions to defer
- Whether to migrate any list page from table-only to card grid (most should, but each is a separate ticket).
- Dark-mode / theming work.
- A real dashboard (KPI page) replacement.
- Replacing the SignalR debug-view streaming model.
@@ -1,8 +1,23 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
<div class="d-flex"> <div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
@* Hamburger toggle: visible only on viewports <lg.
Bootstrap collapse JS lives in bootstrap.bundle.min.js (loaded in App.razor). *@
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
type="button"
data-bs-toggle="collapse"
data-bs-target="#sidebar-collapse"
aria-controls="sidebar-collapse"
aria-expanded="false"
aria-label="Toggle navigation">
&#9776;
</button>
<div class="collapse d-lg-block" id="sidebar-collapse">
<NavMenu /> <NavMenu />
<main class="flex-grow-1 p-3" style="min-height: 100vh; background-color: #f8f9fa;"> </div>
<main class="flex-grow-1 p-3" style="background-color: #f8f9fa;">
@Body @Body
</main> </main>
</div> </div>
@@ -3,7 +3,8 @@
<nav class="sidebar d-flex flex-column"> <nav class="sidebar d-flex flex-column">
<div class="brand">ScadaLink</div> <div class="brand">ScadaLink</div>
<ul class="nav flex-column flex-grow-1"> <div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
<ul class="nav flex-column">
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink> <NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
</li> </li>
@@ -13,7 +14,7 @@
@* Admin section — Admin role only *@ @* Admin section — Admin role only *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin"> <AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="adminContext"> <Authorized Context="adminContext">
<li class="nav-section-header">Admin</li> <div role="presentation" class="nav-section-header">Admin</div>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink> <NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
</li> </li>
@@ -32,7 +33,7 @@
@* Design section — Design role *@ @* Design section — Design role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign"> <AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
<Authorized Context="designContext"> <Authorized Context="designContext">
<li class="nav-section-header">Design</li> <div role="presentation" class="nav-section-header">Design</div>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/design/templates">Templates</NavLink> <NavLink class="nav-link" href="/design/templates">Templates</NavLink>
</li> </li>
@@ -42,13 +43,16 @@
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink> <NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
</li> </li>
<li class="nav-item">
<NavLink class="nav-link" href="/design/smtp">SMTP Configuration</NavLink>
</li>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@* Deployment section — Deployment role *@ @* Deployment section — Deployment role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment"> <AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="deploymentContext"> <Authorized Context="deploymentContext">
<li class="nav-section-header">Deployment</li> <div role="presentation" class="nav-section-header">Deployment</div>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink> <NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
</li> </li>
@@ -62,7 +66,7 @@
</AuthorizeView> </AuthorizeView>
@* Monitoring — visible to all authenticated users *@ @* Monitoring — visible to all authenticated users *@
<li class="nav-section-header">Monitoring</li> <div role="presentation" class="nav-section-header">Monitoring</div>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink> <NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
</li> </li>
@@ -84,6 +88,7 @@
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
</ul> </ul>
</div>
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
@@ -6,20 +6,31 @@
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository @inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IJSRuntime JS
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3"> <div class="d-flex align-items-center mb-3">
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2"
aria-label="Back to API Keys">&larr; Back</a>
<span class="text-muted me-2">·</span>
<h4 class="mb-0">
@if (_saved) @if (_saved)
{ {
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">&larr; Back to API Keys</a> @:API Key Created
}
else if (IsEditMode)
{
@:Edit API Key
} }
else else
{ {
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">&larr; Back</a> @:Add API Key
} }
<h4 class="mb-0">@(_saved ? "API Key Created" : (_editingKey != null ? "Edit API Key" : "Add API Key"))</h4> </h4>
</div> </div>
<ToastNotification @ref="_toast" />
@if (_loading) @if (_loading)
{ {
<LoadingSpinner IsLoading="true" /> <LoadingSpinner IsLoading="true" />
@@ -42,8 +53,6 @@
} }
else else
{ {
<div class="card">
<div class="card-body">
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Name</label> <label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" /> <input type="text" class="form-control form-control-sm" @bind="_formName" />
@@ -56,14 +65,14 @@
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button> <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> <button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div> </div>
</div>
</div>
} }
</div> </div>
@code { @code {
[Parameter] public int? Id { get; set; } [Parameter] public int? Id { get; set; }
private bool IsEditMode => _editingKey != null;
private ApiKey? _editingKey; private ApiKey? _editingKey;
private string _formName = string.Empty; private string _formName = string.Empty;
private string? _formError; private string? _formError;
@@ -72,6 +81,8 @@
private bool _loading = true; private bool _loading = true;
private bool _saved; private bool _saved;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try try
@@ -128,10 +139,18 @@
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys"); private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
private void CopyKeyToClipboard() private async Task CopyKeyToClipboard()
{ {
// Note: JS interop for clipboard would be needed for actual copy. if (_newlyCreatedKeyValue == null) return;
// For now the key is displayed for manual copy. try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedKeyValue);
_toast.ShowSuccess("Copied to clipboard.");
}
catch
{
_toast.ShowError("Copy failed.");
}
} }
private static string GenerateApiKey() private static string GenerateApiKey()
@@ -13,7 +13,7 @@
</div> </div>
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" /> <ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading) @if (_loading)
{ {
@@ -24,6 +24,22 @@
<div class="alert alert-danger">@_errorMessage</div> <div class="alert alert-danger">@_errorMessage</div>
} }
else else
{
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name…"
@bind="_search" @bind:event="oninput" />
</div>
@if (_keys.Count == 0)
{
<p class="text-muted text-center">No API keys configured.</p>
}
else if (!FilteredKeys.Any())
{
<p class="text-muted small">No API keys match the filter.</p>
}
else
{ {
<table class="table table-sm table-striped table-hover"> <table class="table table-sm table-striped table-hover">
<thead class="table-dark"> <thead class="table-dark">
@@ -31,64 +47,71 @@
<th>ID</th> <th>ID</th>
<th>Name</th> <th>Name</th>
<th>Key Value</th> <th>Key Value</th>
<th>Status</th> <th style="width: 160px;">Actions</th>
<th style="width: 240px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (_keys.Count == 0) @foreach (var key in FilteredKeys)
{ {
<tr> <tr @key="key.Id">
<td colspan="5" class="text-muted text-center">No API keys configured.</td>
</tr>
}
@foreach (var key in _keys)
{
<tr>
<td>@key.Id</td> <td>@key.Id</td>
<td>@key.Name</td>
<td><code>@MaskKeyValue(key.KeyValue)</code></td>
<td> <td>
@if (key.IsEnabled) @key.Name
@if (!key.IsEnabled)
{ {
<span class="badge bg-success">Enabled</span> <span class="badge bg-secondary ms-1">Disabled</span>
}
else
{
<span class="badge bg-secondary">Disabled</span>
} }
</td> </td>
<td><code>@MaskKeyValue(key.KeyValue)</code></td>
<td> <td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" <div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm py-0 px-2"
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button> @onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
@if (key.IsEnabled) <div class="dropdown">
{ <button class="btn btn-outline-secondary btn-sm py-0 px-2"
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1" data-bs-toggle="dropdown"
@onclick="() => ToggleKey(key)">Disable</button> aria-label="@($"More actions for {key.Name}")">⋮</button>
} <ul class="dropdown-menu dropdown-menu-end">
else <li>
{ <button class="dropdown-item"
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1" @onclick="() => ToggleKey(key)">
@onclick="() => ToggleKey(key)">Enable</button> @(key.IsEnabled ? "Disable" : "Enable")
} </button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" </li>
@onclick="() => DeleteKey(key)">Delete</button> <li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteKey(key)">
Delete
</button>
</li>
</ul>
</div>
</div>
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
} }
}
</div> </div>
@code { @code {
private List<ApiKey> _keys = new(); private List<ApiKey> _keys = new();
private bool _loading = true; private bool _loading = true;
private string? _errorMessage; private string? _errorMessage;
private string _search = string.Empty;
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!; private ConfirmDialog _confirmDialog = default!;
private IEnumerable<ApiKey> FilteredKeys =>
string.IsNullOrWhiteSpace(_search)
? _keys
: _keys.Where(k =>
k.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false);
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadDataAsync(); await LoadDataAsync();
@@ -32,9 +32,11 @@
<label class="form-label small">Site</label> <label class="form-label small">Site</label>
@if (_siteLocked) @if (_siteLocked)
{ {
<select class="form-select form-select-sm" disabled> <input type="text"
<option>@_siteName</option> class="form-control form-control-plaintext form-control-sm"
</select> readonly
value="@_siteName" />
<div class="form-text">Site is locked after creation.</div>
} }
else else
{ {
@@ -52,13 +54,20 @@
<input type="text" class="form-control form-control-sm" @bind="_formName" /> <input type="text" class="form-control form-control-sm" @bind="_formName" />
</div> </div>
<h6 class="text-muted mt-3">Primary endpoint</h6>
<OpcUaEndpointEditor Title="Primary Endpoint" <OpcUaEndpointEditor Title="Primary Endpoint"
IdPrefix="primary" IdPrefix="primary"
Config="_primaryConfig" Config="_primaryConfig"
IsLegacy="_primaryIsLegacy" IsLegacy="_primaryIsLegacy"
Errors="_primaryErrors" /> Errors="_primaryErrors" />
<h6 class="text-muted border-bottom pb-1 mt-3">Backup Endpoint</h6> <h6 class="text-muted mt-3">
Backup endpoint
@if (!_showBackup)
{
<span class="badge bg-light text-muted border ms-2">Optional</span>
}
</h6>
@if (!_showBackup) @if (!_showBackup)
{ {
<div class="mb-3"> <div class="mb-3">
@@ -77,7 +86,7 @@
<label class="form-label small">Failover Retry Count</label> <label class="form-label small">Failover Retry Count</label>
<input type="number" class="form-control form-control-sm" style="max-width: 120px;" <input type="number" class="form-control form-control-sm" style="max-width: 120px;"
min="1" max="20" @bind="_formFailoverRetryCount" /> min="1" max="20" @bind="_formFailoverRetryCount" />
<div class="form-text">Retries on active endpoint before switching to backup (default: 3)</div> <div class="form-text">Retries before failing over to backup endpoint.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<button type="button" class="btn btn-outline-danger btn-sm" <button type="button" class="btn btn-outline-danger btn-sm"
@@ -8,8 +8,35 @@
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Connections</h4>
<div class="d-flex gap-2">
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
data-bs-toggle="dropdown">
Bulk actions
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @onclick="() => _tree?.ExpandAll()">
Expand all
</button>
</li>
<li>
<button class="dropdown-item" @onclick="() => _tree?.CollapseAll()">
Collapse all
</button>
</li>
</ul>
</div>
<button class="btn btn-primary btn-sm"
disabled="@(!HasSiteSelected)"
@onclick="OnAddConnectionClicked">+ Connection</button>
</div>
</div>
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" /> <ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading) @if (_loading)
{ {
@@ -21,21 +48,17 @@
} }
else else
{ {
<h6 class="mb-2">Connections</h6> <div class="mb-3" style="max-width: 320px;">
<div class="d-flex align-items-center mb-2 gap-2"> <input type="text" class="form-control form-control-sm"
<input type="text" class="form-control form-control-sm" style="max-width: 320px;"
placeholder="Search sites or connections..." placeholder="Search sites or connections..."
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" /> @bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary"
disabled="@(!HasSiteSelected)"
@onclick="OnAddConnectionClicked">+ Connection</button>
<button class="btn btn-outline-secondary" @onclick="LoadDataAsync">Refresh</button>
<button class="btn btn-outline-secondary" @onclick="() => _tree?.ExpandAll()">Expand</button>
<button class="btn btn-outline-secondary" @onclick="() => _tree?.CollapseAll()">Collapse</button>
</div>
</div> </div>
@if (!string.IsNullOrWhiteSpace(_searchText) && _matchKeys.Count == 0 && _treeRoots.Count > 0)
{
<p class="text-muted small">No connections match the filter.</p>
}
<TreeView @ref="_tree" TItem="DcTreeNode" Items="_treeRoots" <TreeView @ref="_tree" TItem="DcTreeNode" Items="_treeRoots"
ChildrenSelector="n => n.Children" ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0" HasChildrenSelector="n => n.Children.Count > 0"
@@ -58,6 +81,41 @@
<span class="tv-label" style="@labelStyle">@node.Label</span> <span class="tv-label" style="@labelStyle">@node.Label</span>
<span class="badge bg-info ms-2">@node.Connection!.Protocol</span> <span class="badge bg-info ms-2">@node.Connection!.Protocol</span>
} }
<span class="tv-meta">
<div class="dropdown dc-node-actions" @onclick:stopPropagation="true">
<button type="button"
class="btn btn-link btn-sm p-0 dc-kebab"
data-bs-toggle="dropdown"
aria-label="@($"More actions for {node.Label}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (node.Kind == DcNodeKind.Site)
{
<li>
<button class="dropdown-item"
@onclick="() => AddConnectionForSite(node.SiteId!.Value)">
Add Connection here
</button>
</li>
}
else
{
<li>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/admin/connections/{node.Connection!.Id}/edit")'>
Edit
</button>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteConnection(node.Connection!)">
Delete
</button>
</li>
}
</ul>
</div>
</span>
</NodeContent> </NodeContent>
<ContextMenu Context="node"> <ContextMenu Context="node">
@if (node.Kind == DcNodeKind.Site) @if (node.Kind == DcNodeKind.Site)
@@ -91,6 +149,24 @@
} }
</div> </div>
<style>
/* Kebab visible-on-hover for tree nodes; always visible at small sizes for touch. */
.dc-node-actions .dc-kebab {
opacity: 0;
line-height: 1;
padding: 0 0.25rem !important;
color: var(--bs-secondary-color);
}
.tv-row:hover .dc-node-actions .dc-kebab,
.dc-node-actions.show .dc-kebab,
.dc-node-actions .dc-kebab:focus {
opacity: 1;
}
@@media (max-width: 768px) {
.dc-node-actions .dc-kebab { opacity: 1; }
}
</style>
@code { @code {
record DcTreeNode(string Key, string Label, DcNodeKind Kind, List<DcTreeNode> Children, record DcTreeNode(string Key, string Label, DcNodeKind Kind, List<DcTreeNode> Children,
int? SiteId = null, DataConnection? Connection = null); int? SiteId = null, DataConnection? Connection = null);
@@ -1,24 +1,28 @@
@page "/admin/ldap-mappings/create" @page "/admin/ldap-mappings/create"
@page "/admin/ldap-mappings/{Id:int}/edit" @page "/admin/ldap-mappings/{Id:int}/edit"
@using ScadaLink.Commons.Entities.Security @using ScadaLink.Commons.Entities.Security
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Security @using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISecurityRepository SecurityRepository @inject ISecurityRepository SecurityRepository
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<div class="mb-3"> <div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack"> <button class="btn btn-outline-secondary btn-sm"
aria-label="Back to LDAP mappings"
@onclick="GoBack">
&larr; Back &larr; Back
</button> </button>
</div> </div>
<ConfirmDialog @ref="_confirmDialog" /> <ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h6 class="card-title">@(IsEditMode ? "Edit LDAP Mapping" : "Add LDAP Mapping")</h6> <h5 class="card-title">Mapping</h5>
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">LDAP Group Name</label> <label class="form-label small">LDAP Group Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formGroupName" /> <input type="text" class="form-control form-control-sm" @bind="_formGroupName" />
@@ -31,6 +35,7 @@
<option value="Design">Design</option> <option value="Design">Design</option>
<option value="Deployment">Deployment</option> <option value="Deployment">Deployment</option>
</select> </select>
<div class="form-text">Deployment role: configure site scope below after saving.</div>
</div> </div>
@if (_formError != null) @if (_formError != null)
{ {
@@ -43,57 +48,61 @@
</div> </div>
</div> </div>
@if (IsEditMode && _formRole.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
{
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h6 class="card-title">Site Scope Rules</h6> <h5 class="card-title">Site Scope Rules</h5>
@if (!IsEditMode)
{
<p class="text-muted small mb-0">Save the mapping first to configure site scope.</p>
}
else
{
@if (_scopeRules.Count > 0) @if (_scopeRules.Count > 0)
{ {
<table class="table table-sm table-striped table-hover mb-3"> <div class="d-flex flex-wrap gap-2 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) @foreach (var rule in _scopeRules)
{ {
<tr> var siteName = _siteLookup.GetValueOrDefault(rule.SiteId)?.Name ?? $"Site {rule.SiteId}";
<td>@rule.Id</td> <span class="badge bg-info text-dark d-inline-flex align-items-center">
<td>@rule.SiteId</td> @siteName
<td> <button type="button"
<button class="btn btn-outline-danger btn-sm py-0 px-1" class="btn-close btn-close-white ms-2"
@onclick="() => DeleteScopeRule(rule)">Delete</button> style="font-size: 0.6rem;"
</td> aria-label="@($"Remove scope rule for {siteName}")"
</tr> @onclick="() => DeleteScopeRule(rule)"></button>
</span>
} }
</tbody> </div>
</table>
} }
else else
{ {
<p class="text-muted small mb-3">All sites (no restrictions)</p> <p class="text-muted small mb-3">All sites (no restrictions)</p>
} }
<div class="mb-2"> <div class="row g-2 align-items-end">
<label class="form-label small">Site ID</label> <div class="col-auto">
<input type="number" class="form-control form-control-sm" @bind="_scopeRuleSiteId" /> <label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_scopeRuleSiteId">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm" @onclick="AddScopeRule">Add scope rule</button>
</div>
</div> </div>
@if (_scopeRuleError != null) @if (_scopeRuleError != null)
{ {
<div class="text-danger small mt-2">@_scopeRuleError</div> <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> </div>
</div>
</div>
@code { @code {
[Parameter] public int? Id { get; set; } [Parameter] public int? Id { get; set; }
@@ -106,6 +115,8 @@
private string? _formError; private string? _formError;
private List<SiteScopeRule> _scopeRules = new(); private List<SiteScopeRule> _scopeRules = new();
private List<Site> _sites = new();
private Dictionary<int, Site> _siteLookup = new();
private int _scopeRuleSiteId; private int _scopeRuleSiteId;
private string? _scopeRuleError; private string? _scopeRuleError;
@@ -113,6 +124,9 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_siteLookup = _sites.ToDictionary(s => s.Id);
if (Id.HasValue) if (Id.HasValue)
{ {
_editingMapping = await SecurityRepository.GetMappingByIdAsync(Id.Value); _editingMapping = await SecurityRepository.GetMappingByIdAsync(Id.Value);
@@ -174,7 +188,7 @@
if (_scopeRuleSiteId <= 0) if (_scopeRuleSiteId <= 0)
{ {
_scopeRuleError = "Site ID must be a positive number."; _scopeRuleError = "Select a site to add a scope rule.";
return; return;
} }
@@ -194,9 +208,10 @@
private async Task DeleteScopeRule(SiteScopeRule rule) private async Task DeleteScopeRule(SiteScopeRule rule)
{ {
var siteName = _siteLookup.GetValueOrDefault(rule.SiteId)?.Name ?? $"Site {rule.SiteId}";
var confirmed = await _confirmDialog.ShowAsync( var confirmed = await _confirmDialog.ShowAsync(
$"Delete scope rule for Site {rule.SiteId}? This cannot be undone.", $"Remove scope rule for '{siteName}'?",
"Delete Scope Rule"); "Remove Scope Rule");
if (!confirmed) return; if (!confirmed) return;
try try
@@ -22,61 +22,75 @@
} }
else else
{ {
@* Mappings table *@ <div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name, LDAP group, or role…"
@bind="_search" @bind:event="oninput" />
</div>
@if (_mappings.Count == 0)
{
<p class="text-muted text-center">No mappings configured.</p>
}
else if (!FilteredMappings.Any())
{
<p class="text-muted small">No mappings match the filter.</p>
}
else
{
<table class="table table-sm table-striped table-hover"> <table class="table table-sm table-striped table-hover">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>LDAP Group Name</th> <th>LDAP Group Name</th>
<th>Role</th> <th>Role</th>
<th>Site Scope Rules</th> <th>Site Scope</th>
<th style="width: 200px;">Actions</th> <th style="width: 160px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (_mappings.Count == 0) @foreach (var mapping in FilteredMappings)
{ {
<tr> var rules = _scopeRules.GetValueOrDefault(mapping.Id);
<td colspan="5" class="text-muted text-center">No mappings configured.</td> var ruleCount = rules?.Count ?? 0;
</tr> <tr @key="mapping.Id">
}
@foreach (var mapping in _mappings)
{
<tr>
<td>@mapping.Id</td> <td>@mapping.Id</td>
<td>@mapping.LdapGroupName</td> <td>@mapping.LdapGroupName</td>
<td><span class="badge bg-secondary">@mapping.Role</span></td> <td><span class="badge bg-secondary">@mapping.Role</span></td>
<td> <td>
@{ @if (ruleCount > 0)
var rules = _scopeRules.GetValueOrDefault(mapping.Id);
}
@if (rules != null && rules.Count > 0)
{ {
@foreach (var rule in rules) <span class="badge bg-info text-dark">@ruleCount rule(s)</span>
{
<span class="badge bg-info text-dark me-1">Site @rule.SiteId</span>
}
} }
else else
{ {
<span class="text-muted small">All sites</span> <span class="text-muted small">All sites</span>
} }
@if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
{
<span class="text-muted small ms-2">(manage on edit page)</span>
}
</td> </td>
<td> <td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" <div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm py-0 px-2"
@onclick='() => NavigationManager.NavigateTo($"/admin/ldap-mappings/{mapping.Id}/edit")'>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" <div class="dropdown">
@onclick="() => DeleteMapping(mapping.Id)">Delete</button> <button class="btn btn-outline-secondary btn-sm py-0 px-2"
data-bs-toggle="dropdown"
aria-label="@($"More actions for {mapping.LdapGroupName}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteMapping(mapping.Id)">
Delete
</button>
</li>
</ul>
</div>
</div>
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
}
} }
</div> </div>
@@ -85,6 +99,14 @@
private Dictionary<int, List<SiteScopeRule>> _scopeRules = new(); private Dictionary<int, List<SiteScopeRule>> _scopeRules = new();
private bool _loading = true; private bool _loading = true;
private string? _errorMessage; private string? _errorMessage;
private string _search = string.Empty;
private IEnumerable<LdapGroupMapping> FilteredMappings =>
string.IsNullOrWhiteSpace(_search)
? _mappings
: _mappings.Where(m =>
(m.LdapGroupName?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) ||
(m.Role?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false));
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -41,7 +41,7 @@
</div> </div>
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" /> <ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading) @if (_loading)
{ {
@@ -1,33 +1,118 @@
@page "/" @page "/"
@attribute [Authorize] @attribute [Authorize]
@using ScadaLink.Commons.Interfaces.Repositories
@inject ISiteRepository SiteRepository
@inject ITemplateEngineRepository TemplateEngineRepository
@inject IInboundApiRepository InboundApiRepository
<div class="container mt-4"> <div class="container-fluid mt-3">
<h3>Welcome to ScadaLink</h3> <div class="d-flex justify-content-between align-items-center mb-3">
<p class="text-muted">Central management console for the ScadaLink SCADA system.</p> <h4 class="mb-0">Welcome to ScadaLink</h4>
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
<div class="card mt-3" style="max-width: 500px;"> <span class="text-muted small">
<div class="card-body"> Signed in as <strong>@context.User.FindFirst("DisplayName")?.Value</strong>
<h6 class="card-subtitle mb-2 text-muted">Signed in as</h6> </span>
<p class="card-text mb-1"><strong>@context.User.FindFirst("DisplayName")?.Value</strong></p>
<p class="card-text small text-muted mb-2">@context.User.FindFirst("Username")?.Value</p>
@{
var roles = context.User.FindAll("Role").Select(c => c.Value).ToList();
}
@if (roles.Count > 0)
{
<h6 class="card-subtitle mb-1 mt-3 text-muted">Roles</h6>
<div>
@foreach (var role in roles)
{
<span class="badge bg-secondary me-1">@role</span>
}
</div>
}
</div>
</div>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
</div> </div>
<p class="text-muted">Central management console for the ScadaLink SCADA system.</p>
@* KPI row *@
<div class="row g-3 mb-4">
<div class="col-lg-3 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<div class="fs-2 fw-bold">@(_loaded ? _siteCount.ToString() : "—")</div>
<div class="text-muted small">Sites configured</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<div class="fs-2 fw-bold">@(_loaded ? _dataConnectionCount.ToString() : "—")</div>
<div class="text-muted small">Data connections configured</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<div class="fs-2 fw-bold">@(_loaded ? _templateCount.ToString() : "—")</div>
<div class="text-muted small">Templates</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<div class="fs-2 fw-bold">@(_loaded ? _apiKeyCount.ToString() : "—")</div>
<div class="text-muted small">API keys</div>
</div>
</div>
</div>
</div>
@* Quick actions *@
<h6 class="text-muted text-uppercase small mb-2">Quick actions</h6>
<div class="row g-3">
<div class="col-lg-4 col-md-6 col-12">
<a class="card h-100 text-decoration-none text-reset" href="/monitoring/health">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<h6 class="mb-1">Health Dashboard</h6>
<span class="text-muted">&rarr;</span>
</div>
<p class="text-muted small mb-0">Live cluster, data connection, and queue health per site.</p>
</div>
</a>
</div>
<div class="col-lg-4 col-md-6 col-12">
<a class="card h-100 text-decoration-none text-reset" href="/monitoring/audit-log">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<h6 class="mb-1">Recent Audit Log</h6>
<span class="text-muted">&rarr;</span>
</div>
<p class="text-muted small mb-0">Browse changes to configuration and deployments.</p>
</div>
</a>
</div>
<div class="col-lg-4 col-md-6 col-12">
<a class="card h-100 text-decoration-none text-reset" href="/design/templates">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<h6 class="mb-1">Templates</h6>
<span class="text-muted">&rarr;</span>
</div>
<p class="text-muted small mb-0">Design templates, shared scripts, and external systems.</p>
</div>
</a>
</div>
</div>
</div>
@code {
private bool _loaded;
private int _siteCount;
private int _dataConnectionCount;
private int _templateCount;
private int _apiKeyCount;
protected override async Task OnInitializedAsync()
{
try
{
_siteCount = (await SiteRepository.GetAllSitesAsync()).Count;
_dataConnectionCount = (await SiteRepository.GetAllDataConnectionsAsync()).Count;
_templateCount = (await TemplateEngineRepository.GetAllTemplatesAsync()).Count;
_apiKeyCount = (await InboundApiRepository.GetAllApiKeysAsync()).Count;
}
catch
{
// Non-fatal — leave counts at zero with the placeholder rendering.
}
_loaded = true;
}
}
@@ -26,6 +26,47 @@
} }
else else
{ {
@* Status strip — connection state, instance, last snapshot. *@
<div class="alert alert-light py-2 mb-3 d-flex justify-content-between align-items-center small flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<strong>
@if (_connected)
{
var inst = _siteInstances.FirstOrDefault(i => i.Id == _selectedInstanceId);
@(inst?.UniqueName ?? "Connected")
}
else
{
<span class="text-muted">Not connected</span>
}
</strong>
@if (_connected)
{
<span class="badge bg-success" aria-label="Connection state: Live">
<span class="spinner-grow spinner-grow-sm me-1" style="width: 0.5rem; height: 0.5rem;" aria-hidden="true"></span>
Live
</span>
}
else
{
<span class="badge bg-secondary" aria-label="Connection state: Disconnected">Disconnected</span>
}
</div>
<div class="d-flex align-items-center gap-2">
@if (_snapshot != null)
{
<span class="text-muted">
Last snapshot: @_snapshot.SnapshotTimestamp.LocalDateTime.ToString("HH:mm:ss")
</span>
}
@if (_connected && _connectedFromStorage)
{
<button class="btn btn-outline-secondary btn-sm" @onclick="StartFresh"
aria-label="Clear persisted selection and disconnect">Start fresh</button>
}
</div>
</div>
<div class="row mb-3 g-2"> <div class="row mb-3 g-2">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label small">Site</label> <label class="form-label small">Site</label>
@@ -52,17 +93,13 @@
{ {
<button class="btn btn-primary btn-sm" @onclick="Connect" <button class="btn btn-primary btn-sm" @onclick="Connect"
disabled="@(_selectedInstanceId == 0 || _selectedSiteId == 0 || _connecting)"> disabled="@(_selectedInstanceId == 0 || _selectedSiteId == 0 || _connecting)">
@if (_connecting) { <span class="spinner-border spinner-border-sm me-1"></span> } @if (_connecting) { <span class="spinner-border spinner-border-sm me-1" role="status" aria-label="Connecting"></span> }
Connect Connect
</button> </button>
} }
else else
{ {
<button class="btn btn-outline-danger btn-sm" @onclick="Disconnect">Disconnect</button> <button class="btn btn-outline-danger btn-sm" @onclick="Disconnect">Disconnect</button>
<span class="badge bg-success align-self-center">
<span class="spinner-grow spinner-grow-sm me-1" style="width: 0.5rem; height: 0.5rem;"></span>
Live
</span>
} }
</div> </div>
</div> </div>
@@ -73,9 +110,25 @@
@* Attribute Values *@ @* Attribute Values *@
<div class="col-md-7"> <div class="col-md-7">
<div class="card"> <div class="card">
<div class="card-header py-2 d-flex justify-content-between"> <div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<strong>Attribute Values</strong> <strong>Attribute Values</strong>
<small class="text-muted">@_attributeValues.Count values</small> <small class="text-muted">@FilteredAttributeValues.Count latest (cap @MaxRows)</small>
</div>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 240px;"
placeholder="Filter by attribute…"
@bind="_attrFilter" @bind:event="oninput" aria-label="Filter attributes" />
<button class="btn btn-link btn-sm py-0" type="button"
@onclick="() => _attrScrollLocked = !_attrScrollLocked"
aria-pressed="@(_attrScrollLocked ? "true" : "false")"
aria-label="@(_attrScrollLocked ? "Scroll locked" : "Auto-scroll enabled")">
@(_attrScrollLocked ? "🔒 Locked" : "🔓 Auto-scroll")
</button>
<button class="btn btn-outline-secondary btn-sm" type="button"
@onclick="ClearAttributes" aria-label="Clear attribute table">Clear</button>
</div>
</div> </div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;"> <div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0"> <table class="table table-sm table-striped mb-0">
@@ -87,16 +140,20 @@
<th>Timestamp</th> <th>Timestamp</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody aria-live="polite" aria-atomic="false">
@foreach (var av in _attributeValues.Values.OrderBy(a => a.AttributeName)) @foreach (var av in FilteredAttributeValues)
{ {
<tr> <tr>
<td class="small">@av.AttributeName</td> <td class="small">@av.AttributeName</td>
<td class="small font-monospace"><strong>@ValueFormatter.FormatDisplayValue(av.Value)</strong></td> <td class="small font-monospace"><strong>@ValueFormatter.FormatDisplayValue(av.Value)</strong></td>
<td> <td>
<span class="badge @GetQualityBadge(av.Quality)">@av.Quality</span> <span class="badge @GetQualityBadge(av.Quality)"
aria-label="@($"Quality: {av.Quality}")">@av.Quality</span>
</td>
<td class="small text-muted"
title="@av.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@av.Timestamp.LocalDateTime.ToString("HH:mm:ss")
</td> </td>
<td class="small text-muted">@av.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -108,9 +165,25 @@
@* Alarm States *@ @* Alarm States *@
<div class="col-md-5"> <div class="col-md-5">
<div class="card"> <div class="card">
<div class="card-header py-2 d-flex justify-content-between"> <div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<strong>Alarm States</strong> <strong>Alarm States</strong>
<small class="text-muted">@_alarmStates.Count alarms</small> <small class="text-muted">@FilteredAlarmStates.Count latest (cap @MaxRows)</small>
</div>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 240px;"
placeholder="Filter by alarm…"
@bind="_alarmFilter" @bind:event="oninput" aria-label="Filter alarms" />
<button class="btn btn-link btn-sm py-0" type="button"
@onclick="() => _alarmScrollLocked = !_alarmScrollLocked"
aria-pressed="@(_alarmScrollLocked ? "true" : "false")"
aria-label="@(_alarmScrollLocked ? "Scroll locked" : "Auto-scroll enabled")">
@(_alarmScrollLocked ? "🔒 Locked" : "🔓 Auto-scroll")
</button>
<button class="btn btn-outline-secondary btn-sm" type="button"
@onclick="ClearAlarms" aria-label="Clear alarm table">Clear</button>
</div>
</div> </div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;"> <div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0"> <table class="table table-sm table-striped mb-0">
@@ -122,16 +195,20 @@
<th>Timestamp</th> <th>Timestamp</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody aria-live="polite" aria-atomic="false">
@foreach (var alarm in _alarmStates.Values.OrderBy(a => a.AlarmName)) @foreach (var alarm in FilteredAlarmStates)
{ {
<tr class="@GetAlarmRowClass(alarm.State)"> <tr class="@GetAlarmRowClass(alarm.State)">
<td class="small">@alarm.AlarmName</td> <td class="small">@alarm.AlarmName</td>
<td> <td>
<span class="badge @GetAlarmStateBadge(alarm.State)">@alarm.State</span> <span class="badge @GetAlarmStateBadge(alarm.State)"
aria-label="@($"Alarm state: {alarm.State}")">@alarm.State</span>
</td> </td>
<td class="small">@alarm.Priority</td> <td class="small">@alarm.Priority</td>
<td class="small text-muted">@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")</td> <td class="small text-muted"
title="@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss")
</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -140,11 +217,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="text-muted small mt-2">
Snapshot received: @_snapshot.SnapshotTimestamp.LocalDateTime.ToString("HH:mm:ss") |
@_attributeValues.Count attributes, @_alarmStates.Count alarms
</div>
} }
else if (_connected) else if (_connected)
{ {
@@ -154,6 +226,8 @@
</div> </div>
@code { @code {
private const int MaxRows = 200;
private List<Site> _sites = new(); private List<Site> _sites = new();
private List<Instance> _siteInstances = new(); private List<Instance> _siteInstances = new();
private int _selectedSiteId; private int _selectedSiteId;
@@ -161,11 +235,36 @@
private bool _loading = true; private bool _loading = true;
private bool _connected; private bool _connected;
private bool _connecting; private bool _connecting;
private bool _connectedFromStorage;
private DebugViewSnapshot? _snapshot; private DebugViewSnapshot? _snapshot;
// Keyed dictionaries hold the latest value per attribute/alarm; insertion order
// is preserved so we can trim the oldest when the count exceeds MaxRows.
private Dictionary<string, AttributeValueChanged> _attributeValues = new(); private Dictionary<string, AttributeValueChanged> _attributeValues = new();
private Dictionary<string, AlarmStateChanged> _alarmStates = new(); private Dictionary<string, AlarmStateChanged> _alarmStates = new();
// Filters and scroll-lock state per table.
private string _attrFilter = string.Empty;
private string _alarmFilter = string.Empty;
private bool _attrScrollLocked;
private bool _alarmScrollLocked;
private IReadOnlyList<AttributeValueChanged> FilteredAttributeValues =>
string.IsNullOrWhiteSpace(_attrFilter)
? _attributeValues.Values.OrderBy(a => a.AttributeName).ToList()
: _attributeValues.Values
.Where(a => a.AttributeName.Contains(_attrFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(a => a.AttributeName)
.ToList();
private IReadOnlyList<AlarmStateChanged> FilteredAlarmStates =>
string.IsNullOrWhiteSpace(_alarmFilter)
? _alarmStates.Values.OrderBy(a => a.AlarmName).ToList()
: _alarmStates.Values
.Where(a => a.AlarmName.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(a => a.AlarmName)
.ToList();
private DebugStreamSession? _session; private DebugStreamSession? _session;
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
@@ -195,8 +294,15 @@
_selectedSiteId = siteId; _selectedSiteId = siteId;
await LoadInstancesForSite(); await LoadInstancesForSite();
_selectedInstanceId = instanceId; _selectedInstanceId = instanceId;
_connectedFromStorage = true;
StateHasChanged(); StateHasChanged();
await Connect(); await Connect();
// Auto-reconnect notice — the user didn't initiate this connection.
var inst = _siteInstances.FirstOrDefault(i => i.Id == instanceId);
_toast.ShowInfo(
$"Auto-reconnected to {inst?.UniqueName ?? "instance"} from previous session.",
autoDismissMs: 8000);
} }
} }
@@ -235,11 +341,11 @@
switch (evt) switch (evt)
{ {
case AttributeValueChanged av: case AttributeValueChanged av:
_attributeValues[av.AttributeName] = av; UpsertWithCap(_attributeValues, av.AttributeName, av);
_ = InvokeAsync(StateHasChanged); _ = InvokeAsync(StateHasChanged);
break; break;
case AlarmStateChanged al: case AlarmStateChanged al:
_alarmStates[al.AlarmName] = al; UpsertWithCap(_alarmStates, al.AlarmName, al);
_ = InvokeAsync(StateHasChanged); _ = InvokeAsync(StateHasChanged);
break; break;
} }
@@ -296,11 +402,51 @@
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceId"); await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceId");
_connected = false; _connected = false;
_connectedFromStorage = false;
_snapshot = null; _snapshot = null;
_attributeValues.Clear(); _attributeValues.Clear();
_alarmStates.Clear(); _alarmStates.Clear();
} }
/// <summary>
/// Disconnect and forget the persisted selection. Surfaces in the status
/// strip whenever the page auto-reconnects from localStorage so the user
/// can opt out of the carry-over session.
/// </summary>
private async Task StartFresh()
{
await Disconnect();
_selectedSiteId = 0;
_selectedInstanceId = 0;
_siteInstances.Clear();
_toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000);
}
private void ClearAttributes()
{
_attributeValues.Clear();
}
private void ClearAlarms()
{
_alarmStates.Clear();
}
/// <summary>
/// Replace or insert a value keyed by name, then trim the oldest entries
/// (queue-style) so the table size never exceeds MaxRows. Dictionary
/// preserves insertion order, so the first key is always the oldest.
/// </summary>
private static void UpsertWithCap<T>(Dictionary<string, T> map, string key, T value)
{
map[key] = value;
while (map.Count > MaxRows)
{
var oldest = map.Keys.First();
map.Remove(oldest);
}
}
private static string GetQualityBadge(string quality) => quality switch private static string GetQualityBadge(string quality) => quality switch
{ {
"Good" => "bg-success", "Good" => "bg-success",
@@ -12,9 +12,12 @@
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Deployment Status</h4> <h4 class="mb-0">Deployment Status</h4>
<div> <div class="d-flex gap-2 align-items-center">
<span class="text-muted small me-2">Auto-refresh: 10s</span> <button class="btn btn-outline-secondary btn-sm" @onclick="ToggleAutoRefresh"
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync">Refresh</button> aria-label="@(_autoRefresh ? "Pause auto-refresh" : "Resume auto-refresh")">
@(_autoRefresh ? "⏸ Pause updates" : "▶ Resume updates")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync" aria-label="Refresh deployments">Refresh</button>
</div> </div>
</div> </div>
@@ -30,7 +33,7 @@
{ {
@* Summary cards *@ @* Summary cards *@
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3"> <div class="col-lg-3 col-md-6 col-12">
<div class="card border-warning"> <div class="card border-warning">
<div class="card-body text-center py-2"> <div class="card-body text-center py-2">
<h4 class="mb-0 text-warning">@_records.Count(r => r.Status == DeploymentStatus.Pending)</h4> <h4 class="mb-0 text-warning">@_records.Count(r => r.Status == DeploymentStatus.Pending)</h4>
@@ -38,7 +41,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-lg-3 col-md-6 col-12">
<div class="card border-info"> <div class="card border-info">
<div class="card-body text-center py-2"> <div class="card-body text-center py-2">
<h4 class="mb-0 text-info">@_records.Count(r => r.Status == DeploymentStatus.InProgress)</h4> <h4 class="mb-0 text-info">@_records.Count(r => r.Status == DeploymentStatus.InProgress)</h4>
@@ -46,7 +49,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-lg-3 col-md-6 col-12">
<div class="card border-success"> <div class="card border-success">
<div class="card-body text-center py-2"> <div class="card-body text-center py-2">
<h4 class="mb-0 text-success">@_records.Count(r => r.Status == DeploymentStatus.Success)</h4> <h4 class="mb-0 text-success">@_records.Count(r => r.Status == DeploymentStatus.Success)</h4>
@@ -54,7 +57,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-lg-3 col-md-6 col-12">
<div class="card border-danger"> <div class="card border-danger">
<div class="card-body text-center py-2"> <div class="card-body text-center py-2">
<h4 class="mb-0 text-danger">@_records.Count(r => r.Status == DeploymentStatus.Failed)</h4> <h4 class="mb-0 text-danger">@_records.Count(r => r.Status == DeploymentStatus.Failed)</h4>
@@ -64,37 +67,51 @@
</div> </div>
</div> </div>
<table class="table table-sm table-striped table-hover"> @if (_records.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-0">No deployments recorded.</p>
</div>
}
else
{
<table class="table table-sm table-striped table-hover align-middle">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th>Deployment ID</th> <th>Deployment</th>
<th>Instance</th> <th>Instance</th>
<th>Status</th> <th>Status</th>
<th>Deployed By</th> <th>Deployed By</th>
<th>Started</th> <th>Started</th>
<th>Completed</th> <th>Completed</th>
<th>Revision</th> <th class="text-end">Actions</th>
<th>Error</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (_records.Count == 0)
{
<tr>
<td colspan="8" class="text-muted text-center">No deployments recorded.</td>
</tr>
}
@foreach (var record in _pagedRecords) @foreach (var record in _pagedRecords)
{ {
<tr class="@GetRowClass(record.Status)"> var rowId = $"deploy-row-{record.DeploymentId}";
<td><code class="small">@record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)]...</code></td> var errorCollapseId = $"deploy-err-{record.DeploymentId}";
var isFailed = record.Status == DeploymentStatus.Failed;
var idShort = record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)];
var revShort = record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)];
<tr id="@rowId" class="@GetRowClass(record.Status)">
<td>
<code class="small">@idShort@(string.IsNullOrEmpty(revShort) ? "" : $"@{revShort}")</code>
</td>
<td>@GetInstanceName(record.InstanceId)</td> <td>@GetInstanceName(record.InstanceId)</td>
<td> <td>
<span class="badge @GetStatusBadge(record.Status)"> @if (isFailed)
{
<i class="bi bi-x-circle text-danger me-1" aria-hidden="true"></i>
}
<span class="badge @GetStatusBadge(record.Status)"
aria-label="@($"Deployment status: {record.Status}")">
@record.Status @record.Status
@if (record.Status == DeploymentStatus.InProgress) @if (record.Status == DeploymentStatus.InProgress)
{ {
<span class="spinner-border spinner-border-sm ms-1" style="width: 0.7rem; height: 0.7rem;"></span> <span class="spinner-border spinner-border-sm ms-1" style="width: 0.7rem; height: 0.7rem;"
role="status" aria-label="Deployment in progress"></span>
} }
</span> </span>
</td> </td>
@@ -112,12 +129,33 @@
<span class="text-muted">—</span> <span class="text-muted">—</span>
} }
</td> </td>
<td class="small"><code>@(record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)])</code></td> <td class="small text-end">
<td class="small text-danger">@(record.ErrorMessage ?? "")</td> @if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage))
{
<button class="btn btn-link btn-sm p-0" type="button"
@onclick="() => ToggleErrorExpansion(record.DeploymentId)"
aria-expanded="@(IsErrorExpanded(record.DeploymentId) ? "true" : "false")"
aria-controls="@errorCollapseId">
@(IsErrorExpanded(record.DeploymentId) ? "Hide error" : "View error")
</button>
}
</td>
</tr> </tr>
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage) && IsErrorExpanded(record.DeploymentId))
{
<tr id="@errorCollapseId" class="table-danger">
<td colspan="7">
<div class="small">
<strong>Error:</strong>
<pre class="mb-0 mt-1 small" style="white-space: pre-wrap; word-break: break-word;">@record.ErrorMessage</pre>
</div>
</td>
</tr>
}
} }
</tbody> </tbody>
</table> </table>
}
@if (_totalPages > 1) @if (_totalPages > 1)
{ {
@@ -149,22 +187,56 @@
private bool _loading = true; private bool _loading = true;
private string? _errorMessage; private string? _errorMessage;
private Timer? _refreshTimer; private Timer? _refreshTimer;
private bool _autoRefresh = true;
private readonly HashSet<string> _expandedErrors = new();
private int _currentPage = 1; private int _currentPage = 1;
private int _totalPages; private int _totalPages;
private const int PageSize = 25; private const int PageSize = 25;
private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(10);
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadDataAsync(); await LoadDataAsync();
StartTimer();
}
private void StartTimer()
{
_refreshTimer?.Dispose();
_refreshTimer = new Timer(_ => _refreshTimer = new Timer(_ =>
{ {
InvokeAsync(async () => InvokeAsync(async () =>
{ {
if (!_autoRefresh) return;
await LoadDataAsync(); await LoadDataAsync();
StateHasChanged(); StateHasChanged();
}); });
}, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); }, null, RefreshInterval, RefreshInterval);
}
private void ToggleAutoRefresh()
{
_autoRefresh = !_autoRefresh;
if (_autoRefresh)
{
StartTimer();
}
else
{
_refreshTimer?.Dispose();
_refreshTimer = null;
}
}
private bool IsErrorExpanded(string deploymentId) => _expandedErrors.Contains(deploymentId);
private void ToggleErrorExpansion(string deploymentId)
{
if (!_expandedErrors.Remove(deploymentId))
{
_expandedErrors.Add(deploymentId);
}
} }
private async Task LoadDataAsync() private async Task LoadDataAsync()
@@ -19,10 +19,11 @@
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@implements IDisposable
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" /> <ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
<CreateAreaDialog @bind-IsVisible="_showCreateAreaDialog" <CreateAreaDialog @bind-IsVisible="_showCreateAreaDialog"
RequireSitePicker="_createAreaRequireSitePicker" RequireSitePicker="_createAreaRequireSitePicker"
@@ -60,8 +61,10 @@
} }
else else
{ {
<h6 class="mb-2">Topology</h6> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center mb-2 gap-2"> <h4 class="mb-0">Topology</h4>
</div>
<div class="d-flex align-items-center mb-2 gap-2 flex-wrap">
<input type="text" class="form-control form-control-sm" style="max-width: 320px;" <input type="text" class="form-control form-control-sm" style="max-width: 320px;"
placeholder="Search sites, areas, instances..." placeholder="Search sites, areas, instances..."
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" /> @bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
@@ -69,13 +72,22 @@
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button> <button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
<button class="btn btn-outline-secondary" <button class="btn btn-outline-secondary"
@onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button> @onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button>
<button class="btn btn-outline-secondary" @onclick="LoadDataAsync">Refresh</button> <button class="btn btn-outline-secondary" aria-label="Refresh topology" @onclick="LoadDataAsync">Refresh</button>
<button class="btn btn-outline-secondary" @onclick="() => _tree?.ExpandAll()">Expand</button> <button class="btn btn-outline-secondary" aria-label="Expand all areas" @onclick="() => _tree?.ExpandAll()">Expand</button>
<button class="btn btn-outline-secondary" @onclick="() => _tree?.CollapseAll()">Collapse</button> <button class="btn btn-outline-secondary" aria-label="Collapse all areas" @onclick="() => _tree?.CollapseAll()">Collapse</button>
</div>
<div class="form-check form-switch ms-2 mb-0">
<input type="checkbox" class="form-check-input" id="live-updates"
checked="@_liveUpdates" @onchange="OnLiveUpdatesToggled" />
<label class="form-check-label small" for="live-updates">Live updates</label>
</div> </div>
</div> </div>
<div style="max-height: calc(100vh - 180px); overflow-y: auto; padding: 4px;"> <div class="alert alert-light py-2 mb-3 small">
@_allAreas.Count area(s) · @_allInstances.Count instance(s) across @_sites.Count site(s).
</div>
<div style="max-height: calc(100vh - 240px); overflow-y: auto; padding: 4px;">
<TreeView @ref="_tree" TItem="TopoNode" Items="_treeRoots" <TreeView @ref="_tree" TItem="TopoNode" Items="_treeRoots"
ChildrenSelector="n => n.Children" ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0" HasChildrenSelector="n => n.Children.Count > 0"
@@ -96,58 +108,7 @@
</TreeView> </TreeView>
</div> </div>
<div class="text-muted small mt-2"> <DiffDialog @ref="_diffDialog" />
@_allInstances.Count instance(s) across @_sites.Count site(s).
</div>
@* Diff Modal — ported from Instances.razor *@
@if (_showDiffModal)
{
<div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Deployment Diff — @_diffInstanceName</h5>
<button type="button" class="btn-close" @onclick="() => _showDiffModal = false"></button>
</div>
<div class="modal-body">
@if (_diffLoading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_diffError != null)
{
<div class="alert alert-danger">@_diffError</div>
}
else if (_diffResult != null)
{
<div class="mb-2">
<span class="badge @(_diffResult.IsStale ? "bg-warning text-dark" : "bg-success")">
@(_diffResult.IsStale ? "Stale — changes pending" : "Current")
</span>
<span class="text-muted small ms-2">
Deployed: @_diffResult.DeployedRevisionHash[..8]
| Current: @_diffResult.CurrentRevisionHash[..8]
| Deployed at: @_diffResult.DeployedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")
</span>
</div>
@if (!_diffResult.IsStale)
{
<p class="text-muted">No differences between deployed and current configuration.</p>
}
else
{
<p class="text-muted small">The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes.</p>
}
}
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" @onclick="() => _showDiffModal = false">Close</button>
</div>
</div>
</div>
</div>
}
} }
</div> </div>
@@ -167,6 +128,12 @@
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!; private ConfirmDialog _confirmDialog = default!;
private DiffDialog _diffDialog = default!;
// ---- Live updates ----
private bool _liveUpdates = true;
private Timer? _liveUpdatesTimer;
private static readonly TimeSpan LiveUpdatesInterval = TimeSpan.FromSeconds(15);
private TreeView<TopoNode> _tree = default!; private TreeView<TopoNode> _tree = default!;
private object? _selectedKey; private object? _selectedKey;
@@ -199,6 +166,41 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadDataAsync(); await LoadDataAsync();
StartLiveUpdatesTimer();
}
private void StartLiveUpdatesTimer()
{
_liveUpdatesTimer?.Dispose();
if (!_liveUpdates) return;
_liveUpdatesTimer = new Timer(_ =>
{
InvokeAsync(async () =>
{
if (!_liveUpdates) return;
await LoadDataAsync();
StateHasChanged();
});
}, null, LiveUpdatesInterval, LiveUpdatesInterval);
}
private void OnLiveUpdatesToggled(ChangeEventArgs e)
{
_liveUpdates = e.Value is bool b && b;
if (_liveUpdates)
{
StartLiveUpdatesTimer();
}
else
{
_liveUpdatesTimer?.Dispose();
_liveUpdatesTimer = null;
}
}
public void Dispose()
{
_liveUpdatesTimer?.Dispose();
} }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -364,6 +366,7 @@
@if (_renamingKey == node.Key) @if (_renamingKey == node.Key)
{ {
<input class="form-control form-control-sm d-inline-block" style="width: auto; max-width: 220px;" <input class="form-control form-control-sm d-inline-block" style="width: auto; max-width: 220px;"
aria-label="@($"Rename {node.Label}")"
@ref="_renameInput" @ref="_renameInput"
@bind="_renameBuffer" @bind="_renameBuffer"
@onkeydown="(e) => OnRenameKeyDown(e, node)" @onkeydown="(e) => OnRenameKeyDown(e, node)"
@@ -386,10 +389,17 @@
<span class="badge @GetStateBadge(node.Instance!.State) ms-1" style="@labelStyle">@node.Instance!.State</span> <span class="badge @GetStateBadge(node.Instance!.State) ms-1" style="@labelStyle">@node.Instance!.State</span>
@if (node.Instance!.State != InstanceState.NotDeployed) @if (node.Instance!.State != InstanceState.NotDeployed)
{ {
<span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1" style="@labelStyle"> @if (node.IsStale)
@(node.IsStale ? "Stale" : "Current") {
<span class="badge bg-warning text-dark ms-1" style="@labelStyle" aria-label="State: Stale">
<i class="bi bi-exclamation-triangle me-1"></i>Stale
</span> </span>
} }
else
{
<span class="badge bg-light text-dark ms-1" style="@labelStyle" aria-label="State: Current">Current</span>
}
}
break; break;
} }
}; };
@@ -787,36 +797,64 @@
} }
// ---- Diff modal ---- // ---- Diff modal ----
private bool _showDiffModal;
private bool _diffLoading;
private string? _diffError;
private string _diffInstanceName = string.Empty;
private DeploymentComparisonResult? _diffResult;
private async Task ShowDiff(Instance inst) private async Task ShowDiff(Instance inst)
{ {
_showDiffModal = true; DeploymentComparisonResult? diffResult = null;
_diffLoading = true; string? diffError = null;
_diffError = null;
_diffResult = null;
_diffInstanceName = inst.UniqueName;
try try
{ {
var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id); var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
if (result.IsSuccess) if (result.IsSuccess)
{ {
_diffResult = result.Value; diffResult = result.Value;
} }
else else
{ {
_diffError = result.Error; diffError = result.Error;
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_diffError = $"Failed to load diff: {ex.Message}"; diffError = $"Failed to load diff: {ex.Message}";
} }
_diffLoading = false;
RenderFragment body = builder =>
{
if (diffError != null)
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "alert alert-danger");
builder.AddContent(2, diffError);
builder.CloseElement();
}
else if (diffResult != null)
{
var stale = diffResult.IsStale;
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "mb-2");
builder.OpenElement(2, "span");
builder.AddAttribute(3, "class", stale ? "badge bg-warning text-dark" : "badge bg-success");
builder.AddContent(4, stale ? "Stale — changes pending" : "Current");
builder.CloseElement();
builder.OpenElement(5, "span");
builder.AddAttribute(6, "class", "text-muted small ms-2");
builder.AddContent(7,
$"Deployed: {diffResult.DeployedRevisionHash[..8]} | " +
$"Current: {diffResult.CurrentRevisionHash[..8]} | " +
$"Deployed at: {diffResult.DeployedAt.LocalDateTime:yyyy-MM-dd HH:mm}");
builder.CloseElement();
builder.CloseElement();
builder.OpenElement(8, "p");
builder.AddAttribute(9, "class", "text-muted small mb-0");
builder.AddContent(10, stale
? "The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes."
: "No differences between deployed and current configuration.");
builder.CloseElement();
}
};
await _diffDialog.ShowAsync($"Deployment Diff — {inst.UniqueName}", body);
} }
// ---- Dropdown option helpers ---- // ---- Dropdown option helpers ----
@@ -30,15 +30,17 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Params (JSON)</label> <label class="form-label">Params (JSON)</label>
<input type="text" class="form-control" @bind="_params" /> <input type="text" class="form-control" @bind="_params"
placeholder='[{"name":"id","type":"Int32"}]' />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Returns (JSON)</label> <label class="form-label">Returns (JSON)</label>
<input type="text" class="form-control" @bind="_returns" /> <input type="text" class="form-control" @bind="_returns"
placeholder='{"type":"Boolean"}' />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Script</label> <label class="form-label">Script</label>
<textarea class="form-control font-monospace" rows="5" @bind="_script" style="font-size: 0.85rem;"></textarea> <textarea class="form-control font-monospace" rows="10" @bind="_script" style="font-size: 0.85rem;"></textarea>
</div> </div>
@if (_formError != null) @if (_formError != null)
@@ -27,6 +27,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Connection String</label> <label class="form-label">Connection String</label>
<input type="text" class="form-control" @bind="_connectionString" /> <input type="text" class="form-control" @bind="_connectionString" />
<div class="form-text">Treat as sensitive — visible to admins only.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Max Retries</label> <label class="form-label">Max Retries</label>
@@ -33,11 +33,13 @@
<select class="form-select" @bind="_authType"> <select class="form-select" @bind="_authType">
<option>ApiKey</option> <option>ApiKey</option>
<option>BasicAuth</option> <option>BasicAuth</option>
<option>None</option>
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Auth Config (JSON)</label> <label class="form-label">Auth Config (JSON)</label>
<input type="text" class="form-control" @bind="_authConfig" /> <input type="text" class="form-control" @bind="_authConfig"
placeholder="@_authConfigPlaceholder" />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Max Retries</label> <label class="form-label">Max Retries</label>
@@ -74,6 +76,14 @@
private ExternalSystemDefinition? _existing; private ExternalSystemDefinition? _existing;
// Per-AuthType placeholder that hints at the expected JSON shape for AuthConfig.
private string _authConfigPlaceholder => _authType switch
{
"ApiKey" => "{\"key\":\"xyz\"}",
"BasicAuth" => "{\"username\":\"u\",\"password\":\"p\"}",
_ => "{}"
};
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (Id.HasValue) if (Id.HasValue)
@@ -11,10 +11,14 @@
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<h4 class="mb-3">Integration Definitions</h4> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Integration Definitions</h4>
<a class="btn btn-outline-secondary btn-sm"
href="/design/smtp">Email configuration →</a>
</div>
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" /> <ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading) @if (_loading)
{ {
@@ -26,33 +30,74 @@
} }
else else
{ {
<ul class="nav nav-tabs mb-3"> <ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"> <li class="nav-item" role="presentation">
<button class="nav-link @(_tab == "extsys" ? "active" : "")" @onclick='() => _tab = "extsys"'> <button class="nav-link @(_tab == "extsys" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "extsys" ? "true" : "false")"
aria-controls="int-tab-extsys"
@onclick='() => _tab = "extsys"'>
External Systems <span class="badge bg-secondary">@_externalSystems.Count</span> External Systems <span class="badge bg-secondary">@_externalSystems.Count</span>
</button> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item" role="presentation">
<button class="nav-link @(_tab == "dbconn" ? "active" : "")" @onclick='() => _tab = "dbconn"'> <button class="nav-link @(_tab == "dbconn" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "dbconn" ? "true" : "false")"
aria-controls="int-tab-dbconn"
@onclick='() => _tab = "dbconn"'>
Database Connections <span class="badge bg-secondary">@_dbConnections.Count</span> Database Connections <span class="badge bg-secondary">@_dbConnections.Count</span>
</button> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item" role="presentation">
<button class="nav-link @(_tab == "notif" ? "active" : "")" @onclick='() => _tab = "notif"'> <button class="nav-link @(_tab == "notif" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "notif" ? "true" : "false")"
aria-controls="int-tab-notif"
@onclick='() => _tab = "notif"'>
Notification Lists <span class="badge bg-secondary">@_notificationLists.Count</span> Notification Lists <span class="badge bg-secondary">@_notificationLists.Count</span>
</button> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item" role="presentation">
<button class="nav-link @(_tab == "inbound" ? "active" : "")" @onclick='() => _tab = "inbound"'> <button class="nav-link @(_tab == "inbound" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "inbound" ? "true" : "false")"
aria-controls="int-tab-inbound"
@onclick='() => _tab = "inbound"'>
Inbound API Methods <span class="badge bg-secondary">@_apiMethods.Count</span> Inbound API Methods <span class="badge bg-secondary">@_apiMethods.Count</span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link @(_tab == "apikeys" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "apikeys" ? "true" : "false")"
aria-controls="int-tab-apikeys"
@onclick='() => _tab = "apikeys"'>
API Keys <span class="badge bg-secondary">@_apiKeys.Count</span>
</button>
</li>
</ul> </ul>
@if (_tab == "extsys") { @RenderExternalSystems() } @if (_tab == "extsys")
else if (_tab == "dbconn") { @RenderDbConnections() } {
else if (_tab == "notif") { @RenderNotificationLists() @RenderSmtpConfig() } <div role="tabpanel" id="int-tab-extsys">@RenderExternalSystems()</div>
else if (_tab == "inbound") { @RenderInboundApiMethods() @RenderApiKeyMethodAssignments() } }
else if (_tab == "dbconn")
{
<div role="tabpanel" id="int-tab-dbconn">@RenderDbConnections()</div>
}
else if (_tab == "notif")
{
<div role="tabpanel" id="int-tab-notif">@RenderNotificationLists()</div>
}
else if (_tab == "inbound")
{
<div role="tabpanel" id="int-tab-inbound">@RenderInboundApiMethods()</div>
}
else if (_tab == "apikeys")
{
<div role="tabpanel" id="int-tab-apikeys">@RenderApiKeys()</div>
}
} }
</div> </div>
@@ -63,27 +108,44 @@
// External Systems // External Systems
private List<ExternalSystemDefinition> _externalSystems = new(); private List<ExternalSystemDefinition> _externalSystems = new();
private string _extsysSearch = "";
private IEnumerable<ExternalSystemDefinition> FilteredExternalSystems =>
string.IsNullOrWhiteSpace(_extsysSearch)
? _externalSystems
: _externalSystems.Where(es => es.Name?.Contains(_extsysSearch, StringComparison.OrdinalIgnoreCase) ?? false);
// Database Connections // Database Connections
private List<DatabaseConnectionDefinition> _dbConnections = new(); private List<DatabaseConnectionDefinition> _dbConnections = new();
private string _dbConnSearch = "";
private IEnumerable<DatabaseConnectionDefinition> FilteredDbConnections =>
string.IsNullOrWhiteSpace(_dbConnSearch)
? _dbConnections
: _dbConnections.Where(dc => dc.Name?.Contains(_dbConnSearch, StringComparison.OrdinalIgnoreCase) ?? false);
// SMTP Configuration // API Keys
private List<SmtpConfiguration> _smtpConfigs = new();
private bool _showSmtpForm;
private SmtpConfiguration? _editingSmtp;
private string _smtpHost = "", _smtpFromAddress = "", _smtpAuthType = "OAuth2";
private int _smtpPort = 587;
private string? _smtpFormError;
// API Key list
private List<ApiKey> _apiKeys = new(); private List<ApiKey> _apiKeys = new();
private string _apiKeySearch = "";
private IEnumerable<ApiKey> FilteredApiKeys =>
string.IsNullOrWhiteSpace(_apiKeySearch)
? _apiKeys
: _apiKeys.Where(k => k.Name?.Contains(_apiKeySearch, StringComparison.OrdinalIgnoreCase) ?? false);
// Notification Lists // Notification Lists
private List<NotificationList> _notificationLists = new(); private List<NotificationList> _notificationLists = new();
private Dictionary<int, List<NotificationRecipient>> _recipients = new(); private Dictionary<int, List<NotificationRecipient>> _recipients = new();
private string _notifSearch = "";
private IEnumerable<NotificationList> FilteredNotificationLists =>
string.IsNullOrWhiteSpace(_notifSearch)
? _notificationLists
: _notificationLists.Where(n => n.Name?.Contains(_notifSearch, StringComparison.OrdinalIgnoreCase) ?? false);
// Inbound API Methods // Inbound API Methods
private List<ApiMethod> _apiMethods = new(); private List<ApiMethod> _apiMethods = new();
private string _apiMethodSearch = "";
private IEnumerable<ApiMethod> FilteredApiMethods =>
string.IsNullOrWhiteSpace(_apiMethodSearch)
? _apiMethods
: _apiMethods.Where(m => m.Name?.Contains(_apiMethodSearch, StringComparison.OrdinalIgnoreCase) ?? false);
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!; private ConfirmDialog _confirmDialog = default!;
@@ -110,7 +172,6 @@
} }
_apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList(); _apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
_smtpConfigs = (await NotificationRepository.GetAllSmtpConfigurationsAsync()).ToList();
_apiKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList(); _apiKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
} }
catch (Exception ex) { _errorMessage = ex.Message; } catch (Exception ex) { _errorMessage = ex.Message; }
@@ -120,27 +181,67 @@
// ==== External Systems ==== // ==== External Systems ====
private RenderFragment RenderExternalSystems() => __builder => private RenderFragment RenderExternalSystems() => __builder =>
{ {
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">External Systems</h6> <h5 class="mb-0">External Systems</h5>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/external-systems/create")'>Add</button> <button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/external-systems/create")'>Add External System</button>
</div> </div>
<table class="table table-sm table-striped"> @if (_externalSystems.Count == 0)
<thead class="table-dark"><tr><th>Name</th><th>URL</th><th>Auth</th><th>Retries</th><th>Delay</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@foreach (var es in _externalSystems)
{ {
<tr> <div class="text-center py-5 text-muted">
<td>@es.Name</td><td class="small">@es.EndpointUrl</td><td><span class="badge bg-secondary">@es.AuthType</span></td> <p class="mb-3">No external systems configured.</p>
<td class="small">@es.MaxRetries</td><td class="small">@es.RetryDelay.TotalSeconds s</td> <button class="btn btn-primary btn-sm"
<td> @onclick='() => NavigationManager.NavigateTo("/design/external-systems/create")'>
<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> Add your first external system
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteExtSys(es)">Delete</button> </button>
</td> </div>
</tr> }
else
{
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name…"
@bind="_extsysSearch" @bind:event="oninput" />
</div>
@if (!FilteredExternalSystems.Any())
{
<p class="text-muted small">No external systems match the filter.</p>
}
<div class="row g-3">
@foreach (var es in FilteredExternalSystems)
{
<div class="col-lg-6 col-12" @key="es.Id">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">@es.Name</h5>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/external-systems/{es.Id}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {es.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item text-danger" @onclick="() => DeleteExtSys(es)">Delete</button></li>
</ul>
</div>
</div>
</div>
<p class="small text-muted text-truncate mb-1" title="@es.EndpointUrl">@es.EndpointUrl</p>
<div>
<span class="badge bg-secondary me-1">@es.AuthType</span>
<span class="badge bg-light text-dark me-1">Max @es.MaxRetries retries</span>
<span class="badge bg-light text-dark">Delay @es.RetryDelay.TotalSeconds s</span>
</div>
</div>
</div>
</div>
}
</div>
} }
</tbody>
</table>
}; };
private async Task DeleteExtSys(ExternalSystemDefinition es) private async Task DeleteExtSys(ExternalSystemDefinition es)
@@ -153,27 +254,66 @@
// ==== Database Connections ==== // ==== Database Connections ====
private RenderFragment RenderDbConnections() => __builder => private RenderFragment RenderDbConnections() => __builder =>
{ {
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Database Connections</h6> <h5 class="mb-0">Database Connections</h5>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/db-connections/create")'>Add</button> <button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/db-connections/create")'>Add Database Connection</button>
</div> </div>
<table class="table table-sm table-striped"> @if (_dbConnections.Count == 0)
<thead class="table-dark"><tr><th>Name</th><th>Connection String</th><th>Retries</th><th>Delay</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@foreach (var dc in _dbConnections)
{ {
<tr> <div class="text-center py-5 text-muted">
<td>@dc.Name</td><td class="small text-muted text-truncate" style="max-width:400px;">@dc.ConnectionString</td> <p class="mb-3">No database connections configured.</p>
<td class="small">@dc.MaxRetries</td><td class="small">@dc.RetryDelay.TotalSeconds s</td> <button class="btn btn-primary btn-sm"
<td> @onclick='() => NavigationManager.NavigateTo("/design/db-connections/create")'>
<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> Add your first database connection
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteDbConn(dc)">Delete</button> </button>
</td> </div>
</tr> }
else
{
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name…"
@bind="_dbConnSearch" @bind:event="oninput" />
</div>
@if (!FilteredDbConnections.Any())
{
<p class="text-muted small">No database connections match the filter.</p>
}
<div class="row g-3">
@foreach (var dc in FilteredDbConnections)
{
<div class="col-lg-6 col-12" @key="dc.Id">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">@dc.Name</h5>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/db-connections/{dc.Id}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {dc.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item text-danger" @onclick="() => DeleteDbConn(dc)">Delete</button></li>
</ul>
</div>
</div>
</div>
<p class="small text-muted text-truncate mb-1" title="@dc.ConnectionString">@dc.ConnectionString</p>
<div>
<span class="badge bg-light text-dark me-1">Max @dc.MaxRetries retries</span>
<span class="badge bg-light text-dark">Delay @dc.RetryDelay.TotalSeconds s</span>
</div>
</div>
</div>
</div>
}
</div>
} }
</tbody>
</table>
}; };
private async Task DeleteDbConn(DatabaseConnectionDefinition dc) private async Task DeleteDbConn(DatabaseConnectionDefinition dc)
@@ -186,39 +326,73 @@
// ==== Notification Lists ==== // ==== Notification Lists ====
private RenderFragment RenderNotificationLists() => __builder => private RenderFragment RenderNotificationLists() => __builder =>
{ {
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Notification Lists</h6> <h5 class="mb-0">Notification Lists</h5>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/notification-lists/create")'>Add List</button> <button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/notification-lists/create")'>Add Notification List</button>
</div> </div>
@foreach (var list in _notificationLists) @if (_notificationLists.Count == 0)
{ {
<div class="card mb-2"> <div class="text-center py-5 text-muted">
<div class="card-header d-flex justify-content-between align-items-center py-2"> <p class="mb-3">No notification lists configured.</p>
<strong>@list.Name</strong> <button class="btn btn-primary btn-sm"
<div> @onclick='() => NavigationManager.NavigateTo("/design/notification-lists/create")'>
<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> Add your first notification list
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteNotifList(list)">Delete</button> </button>
</div> </div>
</div>
<div class="card-body p-2">
@{
var recips = _recipients.GetValueOrDefault(list.Id);
}
@if (recips == null || recips.Count == 0)
{
<span class="text-muted small">No recipients.</span>
} }
else else
{ {
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name…"
@bind="_notifSearch" @bind:event="oninput" />
</div>
@if (!FilteredNotificationLists.Any())
{
<p class="text-muted small">No notification lists match the filter.</p>
}
<div class="row g-3">
@foreach (var list in FilteredNotificationLists)
{
var recips = _recipients.GetValueOrDefault(list.Id);
<div class="col-lg-6 col-12" @key="list.Id">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">@list.Name</h5>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/notification-lists/{list.Id}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {list.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item text-danger" @onclick="() => DeleteNotifList(list)">Delete</button></li>
</ul>
</div>
</div>
</div>
@if (recips == null || recips.Count == 0)
{
<p class="text-muted small fst-italic mb-0">No recipients.</p>
}
else
{
<div>
@foreach (var r in recips) @foreach (var r in recips)
{ {
<span class="badge bg-light text-dark me-1 mb-1"> <span class="badge bg-light text-dark me-1 mb-1">@r.Name &lt;@r.EmailAddress&gt;</span>
@r.Name &lt;@r.EmailAddress&gt;
</span>
}
} }
</div> </div>
}
</div>
</div>
</div>
}
</div> </div>
} }
}; };
@@ -233,28 +407,69 @@
// ==== Inbound API Methods ==== // ==== Inbound API Methods ====
private RenderFragment RenderInboundApiMethods() => __builder => private RenderFragment RenderInboundApiMethods() => __builder =>
{ {
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Inbound API Methods</h6> <h5 class="mb-0">Inbound API Methods</h5>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/api-methods/create")'>Add Method</button> <button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/api-methods/create")'>Add API Method</button>
</div> </div>
<table class="table table-sm table-striped"> @if (_apiMethods.Count == 0)
<thead class="table-dark"><tr><th>Name</th><th>Timeout</th><th>Script (preview)</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@foreach (var m in _apiMethods)
{ {
<tr> <div class="text-center py-5 text-muted">
<td><code>POST /api/@m.Name</code></td> <p class="mb-3">No API methods configured.</p>
<td>@m.TimeoutSeconds s</td> <button class="btn btn-primary btn-sm"
<td class="small font-monospace text-truncate" style="max-width:300px;">@m.Script[..Math.Min(60, m.Script.Length)]</td> @onclick='() => NavigationManager.NavigateTo("/design/api-methods/create")'>
<td> Add your first API method
<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>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteApiMethod(m)">Delete</button> </div>
</td> }
</tr> else
{
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name…"
@bind="_apiMethodSearch" @bind:event="oninput" />
</div>
@if (!FilteredApiMethods.Any())
{
<p class="text-muted small">No API methods match the filter.</p>
}
<div class="row g-3">
@foreach (var m in FilteredApiMethods)
{
var preview = m.Script.Length > 80 ? m.Script[..80] + "…" : m.Script;
<div class="col-lg-6 col-12" @key="m.Id">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-1">@m.Name</h5>
<code class="small">POST /api/@m.Name</code>
</div>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/api-methods/{m.Id}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {m.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item text-danger" @onclick="() => DeleteApiMethod(m)">Delete</button></li>
</ul>
</div>
</div>
</div>
<pre class="small text-muted font-monospace mb-2"
style="white-space: pre-wrap; word-break: break-all;"
title="@m.Script">@preview</pre>
<span class="badge bg-light text-dark">Timeout @m.TimeoutSeconds s</span>
</div>
</div>
</div>
}
</div>
} }
</tbody>
</table>
}; };
private async Task DeleteApiMethod(ApiMethod m) private async Task DeleteApiMethod(ApiMethod m)
@@ -264,115 +479,56 @@
catch (Exception ex) { _toast.ShowError(ex.Message); } catch (Exception ex) { _toast.ShowError(ex.Message); }
} }
// ==== SMTP Configuration ==== // ==== API Keys ====
private RenderFragment RenderSmtpConfig() => __builder => private RenderFragment RenderApiKeys() => __builder =>
{ {
<hr class="my-3" /> <div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex justify-content-between mb-2"> <h5 class="mb-0">API Keys</h5>
<h6 class="mb-0">SMTP Configuration</h6>
@if (_smtpConfigs.Count == 0)
{
<button class="btn btn-primary btn-sm" @onclick="ShowSmtpAddForm">Add SMTP Config</button>
}
</div> </div>
@if (_showSmtpForm) @if (_apiKeys.Count == 0)
{ {
<div class="card mb-2"><div class="card-body"> <div class="text-center py-5 text-muted">
<div class="row g-2 align-items-end"> <p class="mb-3">No API keys configured. Add your first API key from the Admin section.</p>
<div class="col-md-3"><label class="form-label small">Host</label><input type="text" class="form-control form-control-sm" @bind="_smtpHost" /></div>
<div class="col-md-1"><label class="form-label small">Port</label><input type="number" class="form-control form-control-sm" @bind="_smtpPort" /></div>
<div class="col-md-2"><label class="form-label small">Auth Type</label>
<select class="form-select form-select-sm" @bind="_smtpAuthType"><option>OAuth2</option><option>Basic</option></select></div>
<div class="col-md-3"><label class="form-label small">From Address</label><input type="email" class="form-control form-control-sm" @bind="_smtpFromAddress" /></div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSmtpConfig">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showSmtpForm = false">Cancel</button></div>
</div> </div>
@if (_smtpFormError != null) { <div class="text-danger small mt-1">@_smtpFormError</div> }
</div></div>
}
@foreach (var smtp in _smtpConfigs)
{
<div class="card mb-2">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<span class="small">
<strong>@smtp.Host</strong>:@smtp.Port |
Auth: <span class="badge bg-secondary">@smtp.AuthType</span> |
From: @smtp.FromAddress
</span>
<button class="btn btn-outline-primary btn-sm py-0 px-1" @onclick="() => { _editingSmtp = smtp; _smtpHost = smtp.Host; _smtpPort = smtp.Port; _smtpAuthType = smtp.AuthType; _smtpFromAddress = smtp.FromAddress; _showSmtpForm = true; }">Edit</button>
</div>
</div>
</div>
}
};
private void ShowSmtpAddForm()
{
_showSmtpForm = true;
_editingSmtp = null;
_smtpHost = string.Empty;
_smtpPort = 587;
_smtpAuthType = "OAuth2";
_smtpFromAddress = string.Empty;
_smtpFormError = null;
}
private async Task SaveSmtpConfig()
{
_smtpFormError = null;
if (string.IsNullOrWhiteSpace(_smtpHost) || string.IsNullOrWhiteSpace(_smtpFromAddress)) { _smtpFormError = "Host and From Address required."; return; }
try
{
if (_editingSmtp != null)
{
_editingSmtp.Host = _smtpHost.Trim();
_editingSmtp.Port = _smtpPort;
_editingSmtp.AuthType = _smtpAuthType;
_editingSmtp.FromAddress = _smtpFromAddress.Trim();
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
} }
else else
{ {
var smtp = new SmtpConfiguration(_smtpHost.Trim(), _smtpAuthType, _smtpFromAddress.Trim()) { Port = _smtpPort }; <div class="mb-3" style="max-width: 320px;">
await NotificationRepository.AddSmtpConfigurationAsync(smtp); <input class="form-control form-control-sm"
} placeholder="Filter by name…"
await NotificationRepository.SaveChangesAsync(); @bind="_apiKeySearch" @bind:event="oninput" />
_showSmtpForm = false;
_toast.ShowSuccess("SMTP configuration saved.");
await LoadAllAsync();
}
catch (Exception ex) { _smtpFormError = ex.Message; }
}
// ==== API Key → Method Assignments ====
private RenderFragment RenderApiKeyMethodAssignments() => __builder =>
{
<hr class="my-3" />
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">API Keys</h6>
</div> </div>
<table class="table table-sm table-striped"> @if (!FilteredApiKeys.Any())
<thead class="table-dark"><tr><th>Key Name</th><th>Enabled</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@foreach (var key in _apiKeys)
{ {
<tr> <p class="text-muted small">No API keys match the filter.</p>
<td>@key.Name</td> }
<td><span class="badge @(key.IsEnabled ? "bg-success" : "bg-secondary")">@(key.IsEnabled ? "Enabled" : "Disabled")</span></td>
<td> <div class="row g-3">
<button class="btn btn-outline-primary btn-sm py-0 px-1" @onclick="() => ToggleApiKeyEnabled(key)"> @foreach (var key in FilteredApiKeys)
{
<div class="col-lg-6 col-12" @key="key.Id">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">@key.Name</h5>
<span class="badge @(key.IsEnabled ? "bg-success" : "bg-secondary")">
@(key.IsEnabled ? "Enabled" : "Disabled")
</span>
</div>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm"
@onclick="() => ToggleApiKeyEnabled(key)">
@(key.IsEnabled ? "Disable" : "Enable") @(key.IsEnabled ? "Disable" : "Enable")
</button> </button>
</td> </div>
</tr> </div>
</div>
</div>
}
</div>
} }
</tbody>
</table>
}; };
private async Task ToggleApiKeyEnabled(ApiKey key) private async Task ToggleApiKeyEnabled(ApiKey key)
@@ -44,25 +44,25 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Name</label> <label class="form-label">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_recipientName" /> <input type="text" class="form-control" @bind="_recipientName" />
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Email</label> <label class="form-label">Email</label>
<input type="email" class="form-control form-control-sm" @bind="_recipientEmail" /> <input type="email" class="form-control" @bind="_recipientEmail" />
</div> </div>
@if (_recipientFormError != null) @if (_recipientFormError != null)
{ {
<div class="text-danger small mt-2">@_recipientFormError</div> <div class="text-danger small mt-2">@_recipientFormError</div>
} }
<div class="mt-3"> <div class="mt-3">
<button class="btn btn-success btn-sm" @onclick="SaveRecipient">Add</button> <button class="btn btn-success" @onclick="SaveRecipient">Add</button>
</div> </div>
</div> </div>
</div> </div>
<table class="table table-sm table-striped"> <table class="table table-sm table-striped">
<thead class="table-dark"> <thead class="table-light">
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Email</th> <th>Email</th>
@@ -30,12 +30,22 @@
disabled="@(Id.HasValue)" /> disabled="@(Id.HasValue)" />
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Parameters (JSON)</label> <label class="form-label small">
Parameters (JSON)
<span class="text-muted" title='JSON array of {name, type} objects. Example: [{"name":"id","type":"Int32"},{"name":"label","type":"String"}]'>
<i class="bi bi-question-circle"></i>
</span>
</label>
<input type="text" class="form-control form-control-sm" @bind="_formParameters" <input type="text" class="form-control form-control-sm" @bind="_formParameters"
placeholder='e.g. [{"name":"x","type":"Int32"}]' /> placeholder='e.g. [{"name":"x","type":"Int32"}]' />
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Return Definition (JSON)</label> <label class="form-label small">
Return Definition (JSON)
<span class="text-muted" title='JSON object with a type field. Example: {"type":"Boolean"} or {"type":"List","itemType":"Int32"}'>
<i class="bi bi-question-circle"></i>
</span>
</label>
<input type="text" class="form-control form-control-sm" @bind="_formReturn" <input type="text" class="form-control form-control-sm" @bind="_formReturn"
placeholder='e.g. {"type":"Boolean"}' /> placeholder='e.g. {"type":"Boolean"}' />
</div> </div>
@@ -16,7 +16,7 @@
</div> </div>
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" /> <ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading) @if (_loading)
{ {
@@ -26,46 +26,81 @@
{ {
<div class="alert alert-danger">@_errorMessage</div> <div class="alert alert-danger">@_errorMessage</div>
} }
else if (_scripts.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No shared scripts configured.</p>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/design/shared-scripts/create")'>
Create your first script
</button>
</div>
}
else else
{ {
<table class="table table-sm table-striped table-hover"> <div class="mb-3" style="max-width: 320px;">
<thead class="table-dark"> <input class="form-control form-control-sm"
<tr> placeholder="Filter by name or code…"
<th>ID</th> @bind="_search" @bind:event="oninput" />
<th>Name</th> </div>
<th>Code (preview)</th>
<th>Parameters</th> @if (!FilteredScripts.Any())
<th>Returns</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@if (_scripts.Count == 0)
{ {
<tr> <p class="text-muted small">No shared scripts match the filter.</p>
<td colspan="6" class="text-muted text-center">No shared scripts configured.</td>
</tr>
} }
@foreach (var script in _scripts)
<div class="row g-3">
@foreach (var s in FilteredScripts)
{ {
<tr> var preview = s.Code.Length > 80
<td>@script.Id</td> ? s.Code[..80] + "…"
<td><strong>@script.Name</strong></td> : s.Code;
<td class="small text-muted font-monospace text-truncate" style="max-width: 300px;"> var paramCount = CountJsonArrayEntries(s.ParameterDefinitions);
@script.Code[..Math.Min(60, script.Code.Length)]@(script.Code.Length > 60 ? "..." : "") <div class="col-lg-6 col-12" @key="s.Id">
</td> <div class="card h-100">
<td class="small text-muted">@(script.ParameterDefinitions ?? "—")</td> <div class="card-body">
<td class="small text-muted">@(script.ReturnDefinition ?? "—")</td> <div class="d-flex justify-content-between align-items-start mb-2">
<td> <h5 class="card-title mb-0">@s.Name</h5>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" <div class="d-flex gap-1">
@onclick='() => NavigationManager.NavigateTo($"/design/shared-scripts/{script.Id}/edit")'>Edit</button> <button class="btn btn-outline-primary btn-sm"
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick='() => NavigationManager.NavigateTo($"/design/shared-scripts/{s.Id}/edit")'>
@onclick="() => DeleteScript(script)">Delete</button> Edit
</td> </button>
</tr> <div class="dropdown">
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {s.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteScript(s)">Delete</button>
</li>
</ul>
</div>
</div>
</div>
<pre class="small text-muted font-monospace mb-2"
style="white-space: pre-wrap; word-break: break-all;"
title="@s.Code">@preview</pre>
<div>
<span class="badge bg-light text-dark me-1">@paramCount params</span>
@if (!string.IsNullOrWhiteSpace(s.ReturnDefinition))
{
<span class="badge bg-light text-dark">returns</span>
} }
</tbody> else
</table> {
<span class="badge bg-light text-dark">void</span>
}
</div>
</div>
</div>
</div>
}
</div>
} }
</div> </div>
@@ -79,10 +114,18 @@
private List<SharedScript> _scripts = new(); private List<SharedScript> _scripts = new();
private bool _loading = true; private bool _loading = true;
private string? _errorMessage; private string? _errorMessage;
private string _search = "";
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!; private ConfirmDialog _confirmDialog = default!;
private IEnumerable<SharedScript> FilteredScripts =>
string.IsNullOrWhiteSpace(_search)
? _scripts
: _scripts.Where(s =>
(s.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) ||
(s.Code?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false));
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadDataAsync(); await LoadDataAsync();
@@ -128,4 +171,21 @@
_toast.ShowError($"Delete failed: {ex.Message}"); _toast.ShowError($"Delete failed: {ex.Message}");
} }
} }
/// <summary>
/// Best-effort count of JSON array entries by tallying top-level objects.
/// Returns 0 if the parameter definition is null/empty/malformed.
/// </summary>
private static int CountJsonArrayEntries(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return 0;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
return doc.RootElement.GetArrayLength();
}
catch { /* fall through */ }
return 0;
}
} }
@@ -0,0 +1,222 @@
@page "/design/smtp"
@using ScadaLink.Security
@using ScadaLink.Commons.Interfaces.Repositories
@using SmtpConfigurationEntity = ScadaLink.Commons.Entities.Notifications.SmtpConfiguration
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject INotificationRepository NotificationRepository
@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">SMTP Configuration</h4>
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
@if (_smtpConfigs.Count == 0 && !_showForm)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No SMTP configuration set.</p>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">
Add SMTP configuration
</button>
</div>
}
else
{
@foreach (var smtp in _smtpConfigs)
{
<div class="card mb-3" @key="smtp.Id">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>@smtp.Host</strong>
@if (_editingSmtp?.Id != smtp.Id || !_showForm)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => StartEdit(smtp)">Edit</button>
}
</div>
<div class="card-body small">
<div class="row g-2">
<div class="col-md-4 text-muted">Host</div>
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
<div class="col-md-4 text-muted">Auth Type</div>
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
<div class="col-md-4 text-muted">From Address</div>
<div class="col-md-8">@smtp.FromAddress</div>
<div class="col-md-4 text-muted">Credentials</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")</div>
</div>
</div>
</div>
}
@if (_showForm)
{
<div class="card mb-3">
<div class="card-header">@(_editingSmtp != null ? "Edit SMTP Configuration" : "Add SMTP Configuration")</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Host</label>
<input type="text" class="form-control" @bind="_host" placeholder="smtp.example.com" />
</div>
<div class="col-md-4">
<label class="form-label">Port</label>
<input type="number" class="form-control" @bind="_port" min="1" max="65535" />
</div>
<div class="col-md-8">
<label class="form-label">Auth Type</label>
<select class="form-select" @bind="_authType">
<option>OAuth2</option>
<option>Basic</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Credentials</label>
<input type="password" class="form-control" @bind="_credentials"
placeholder="OAuth2 client secret or SMTP password" />
<div class="form-text">Treat as sensitive — visible to admins only.</div>
</div>
<div class="col-12">
<label class="form-label">From Address</label>
<input type="email" class="form-control" @bind="_fromAddress"
placeholder="noreply@example.com" />
</div>
@if (_formError != null)
{
<div class="col-12"><div class="text-danger small">@_formError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
<button class="btn btn-success" @onclick="Save">Save</button>
</div>
</div>
</div>
</div>
}
else if (_smtpConfigs.Count == 0)
{
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add SMTP configuration</button>
}
}
}
</div>
@code {
private bool _loading = true;
private string? _errorMessage;
private List<SmtpConfigurationEntity> _smtpConfigs = new();
private bool _showForm;
private SmtpConfigurationEntity? _editingSmtp;
private string _host = string.Empty;
private int _port = 587;
private string _authType = "OAuth2";
private string? _credentials;
private string _fromAddress = string.Empty;
private string? _formError;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_errorMessage = null;
try
{
_smtpConfigs = (await NotificationRepository.GetAllSmtpConfigurationsAsync()).ToList();
}
catch (Exception ex)
{
_errorMessage = ex.Message;
}
_loading = false;
}
private void ShowAddForm()
{
_editingSmtp = null;
_host = string.Empty;
_port = 587;
_authType = "OAuth2";
_credentials = null;
_fromAddress = string.Empty;
_formError = null;
_showForm = true;
}
private void StartEdit(SmtpConfigurationEntity smtp)
{
_editingSmtp = smtp;
_host = smtp.Host;
_port = smtp.Port;
_authType = smtp.AuthType;
_credentials = smtp.Credentials;
_fromAddress = smtp.FromAddress;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_formError = null;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_host) || string.IsNullOrWhiteSpace(_fromAddress))
{
_formError = "Host and From Address are required.";
return;
}
try
{
if (_editingSmtp != null)
{
_editingSmtp.Host = _host.Trim();
_editingSmtp.Port = _port;
_editingSmtp.AuthType = _authType;
_editingSmtp.Credentials = _credentials?.Trim();
_editingSmtp.FromAddress = _fromAddress.Trim();
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
}
else
{
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
{
Port = _port,
Credentials = _credentials?.Trim()
};
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
}
await NotificationRepository.SaveChangesAsync();
_showForm = false;
_toast.ShowSuccess("SMTP configuration saved.");
await LoadAsync();
}
catch (Exception ex)
{
_formError = ex.Message;
}
}
}
@@ -11,7 +11,9 @@
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<div class="mb-3"> <div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">&larr; Back</button> <button class="btn btn-outline-secondary btn-sm"
aria-label="Back to Templates"
@onclick="GoBack">← Back</button>
</div> </div>
<h4 class="mb-3">Create Template</h4> <h4 class="mb-3">Create Template</h4>
@@ -24,13 +26,13 @@
{ {
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="mb-2"> <div class="mb-3">
<label class="form-label small">Name</label> <label class="form-label">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_createName" /> <input type="text" class="form-control" @bind="_createName" />
</div> </div>
<div class="mb-2"> <div class="mb-3">
<label class="form-label small">Parent Template</label> <label class="form-label">Parent Template</label>
<select class="form-select form-select-sm" @bind="_createParentId"> <select class="form-select" @bind="_createParentId">
<option value="0">(None - root template)</option> <option value="0">(None - root template)</option>
@foreach (var t in _templates) @foreach (var t in _templates)
{ {
@@ -38,17 +40,17 @@
} }
</select> </select>
</div> </div>
<div class="mb-2"> <div class="mb-3">
<label class="form-label small">Description</label> <label class="form-label">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_createDescription" /> <input type="text" class="form-control" @bind="_createDescription" />
</div> </div>
@if (_formError != null) @if (_formError != null)
{ {
<div class="text-danger small mt-2">@_formError</div> <div class="text-danger small mt-2">@_formError</div>
} }
<div class="mt-3"> <div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="CreateTemplate">Create</button> <button class="btn btn-success me-1" @onclick="CreateTemplate">Create</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button> <button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div> </div>
</div> </div>
</div> </div>
@@ -14,10 +14,12 @@
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" /> <ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
<div class="mb-3"> <div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">&larr; Templates</button> <button class="btn btn-outline-secondary btn-sm"
aria-label="Back to Templates"
@onclick="GoBack">← Templates</button>
</div> </div>
@if (_loading) @if (_loading)
@@ -173,7 +175,13 @@
<ul class="mb-0 small"> <ul class="mb-0 small">
@foreach (var err in _validationResult.Errors) @foreach (var err in _validationResult.Errors)
{ {
<li>[@err.Category] @err.Message @(err.EntityName != null ? $"({err.EntityName})" : "")</li> <li class="mb-1">
<strong>@err.Category</strong> @err.Message
@if (err.EntityName != null)
{
<span class="text-muted">(@err.EntityName)</span>
}
</li>
} }
</ul> </ul>
</div> </div>
@@ -185,7 +193,9 @@
<ul class="mb-0 small"> <ul class="mb-0 small">
@foreach (var warn in _validationResult.Warnings) @foreach (var warn in _validationResult.Warnings)
{ {
<li>[@warn.Category] @warn.Message</li> <li class="mb-1">
<strong>@warn.Category</strong> <span class="text-muted">@warn.Message</span>
</li>
} }
</ul> </ul>
</div> </div>
@@ -201,49 +211,64 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header">Template Properties</div> <div class="card-header">Template Properties</div>
<div class="card-body"> <div class="card-body">
<div class="row g-2 align-items-end"> <div class="row g-3">
<div class="col-md-3"> <div class="col-12">
<label class="form-label small">Name</label> <label class="form-label">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_editName" /> <input type="text" class="form-control" @bind="_editName" />
</div> </div>
<div class="col-md-4"> <div class="col-12">
<label class="form-label small">Description</label> <label class="form-label">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_editDescription" /> <input type="text" class="form-control" @bind="_editDescription" />
</div> </div>
<div class="col-md-3"> <div class="col-12">
<label class="form-label small">Parent Template</label> <label class="form-label">Parent Template</label>
<div class="form-control-plaintext form-control-sm"> <input type="text" readonly class="form-control form-control-plaintext"
@(_selectedTemplate.ParentTemplateId is int pid value="@(_selectedTemplate.ParentTemplateId is int pid
? _templates.FirstOrDefault(t => t.Id == pid)?.Name ?? $"#{pid}" ? _templates.FirstOrDefault(t => t.Id == pid)?.Name ?? $"#{pid}"
: "(none)") : "(none)")" />
</div> </div>
</div> <div class="col-12 text-end">
<div class="col-md-2"> <button class="btn btn-primary" @onclick="UpdateTemplateProperties">Save Properties</button>
<button class="btn btn-primary btn-sm" @onclick="UpdateTemplateProperties">Save Properties</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@* Tabs: Attributes, Alarms, Scripts, Compositions *@ @* Tabs: Attributes, Alarms, Scripts, Compositions *@
<ul class="nav nav-tabs mb-3"> <ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"> <li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "attributes" ? "active" : "")" @onclick='() => _activeTab = "attributes"'> <button class="nav-link @(_activeTab == "attributes" ? "active" : "")"
role="tab"
aria-selected="@(_activeTab == "attributes" ? "true" : "false")"
aria-controls="tmpl-tab-attributes"
@onclick='() => _activeTab = "attributes"'>
Attributes <span class="badge bg-secondary">@_attributes.Count</span> Attributes <span class="badge bg-secondary">@_attributes.Count</span>
</button> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "alarms" ? "active" : "")" @onclick='() => _activeTab = "alarms"'> <button class="nav-link @(_activeTab == "alarms" ? "active" : "")"
role="tab"
aria-selected="@(_activeTab == "alarms" ? "true" : "false")"
aria-controls="tmpl-tab-alarms"
@onclick='() => _activeTab = "alarms"'>
Alarms <span class="badge bg-secondary">@_alarms.Count</span> Alarms <span class="badge bg-secondary">@_alarms.Count</span>
</button> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "scripts" ? "active" : "")" @onclick='() => _activeTab = "scripts"'> <button class="nav-link @(_activeTab == "scripts" ? "active" : "")"
role="tab"
aria-selected="@(_activeTab == "scripts" ? "true" : "false")"
aria-controls="tmpl-tab-scripts"
@onclick='() => _activeTab = "scripts"'>
Scripts <span class="badge bg-secondary">@_scripts.Count</span> Scripts <span class="badge bg-secondary">@_scripts.Count</span>
</button> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "compositions" ? "active" : "")" @onclick='() => _activeTab = "compositions"'> <button class="nav-link @(_activeTab == "compositions" ? "active" : "")"
role="tab"
aria-selected="@(_activeTab == "compositions" ? "true" : "false")"
aria-controls="tmpl-tab-compositions"
@onclick='() => _activeTab = "compositions"'>
Compositions <span class="badge bg-secondary">@_compositions.Count</span> Compositions <span class="badge bg-secondary">@_compositions.Count</span>
</button> </button>
</li> </li>
@@ -251,19 +276,19 @@
@if (_activeTab == "attributes") @if (_activeTab == "attributes")
{ {
@RenderAttributesTab() <div role="tabpanel" id="tmpl-tab-attributes">@RenderAttributesTab()</div>
} }
else if (_activeTab == "alarms") else if (_activeTab == "alarms")
{ {
@RenderAlarmsTab() <div role="tabpanel" id="tmpl-tab-alarms">@RenderAlarmsTab()</div>
} }
else if (_activeTab == "scripts") else if (_activeTab == "scripts")
{ {
@RenderScriptsTab() <div role="tabpanel" id="tmpl-tab-scripts">@RenderScriptsTab()</div>
} }
else if (_activeTab == "compositions") else if (_activeTab == "compositions")
{ {
@RenderCompositionsTab() <div role="tabpanel" id="tmpl-tab-compositions">@RenderCompositionsTab()</div>
} }
}; };
@@ -383,48 +408,52 @@
private RenderFragment RenderAttributesTab() => __builder => private RenderFragment RenderAttributesTab() => __builder =>
{ {
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Attributes</h6> <h5 class="mb-0">Attributes</h5>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAttrForm = true; _attrFormError = null; _attrName = string.Empty; _attrValue = null; _attrIsLocked = false; _attrDataSourceRef = null; }">Add Attribute</button> <button class="btn btn-primary btn-sm" @onclick="() => { _showAttrForm = true; _attrFormError = null; _attrName = string.Empty; _attrValue = null; _attrIsLocked = false; _attrDataSourceRef = null; }">Add Attribute</button>
</div> </div>
@if (_showAttrForm) @if (_showAttrForm)
{ {
<div class="card mb-2"> <div class="card mb-3">
<div class="card-header">Add Attribute</div>
<div class="card-body"> <div class="card-body">
<div class="row g-2 align-items-end"> <div class="row g-3">
<div class="col-md-2"> <div class="col-12">
<label class="form-label small">Name</label> <label class="form-label">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_attrName" /> <input type="text" class="form-control" @bind="_attrName" />
</div> </div>
<div class="col-md-2"> <div class="col-12">
<label class="form-label small">Data Type</label> <label class="form-label">Data Type</label>
<select class="form-select form-select-sm" @bind="_attrDataType"> <select class="form-select" @bind="_attrDataType">
@foreach (var dt in Enum.GetValues<DataType>()) @foreach (var dt in Enum.GetValues<DataType>())
{ {
<option value="@dt">@dt</option> <option value="@dt">@dt</option>
} }
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-12">
<label class="form-label small">Value</label> <label class="form-label">Value</label>
<input type="text" class="form-control form-control-sm" @bind="_attrValue" /> <input type="text" class="form-control" @bind="_attrValue" />
</div> </div>
<div class="col-md-2"> <div class="col-12">
<label class="form-label small">Data Source Ref</label> <label class="form-label">Data Source Ref</label>
<input type="text" class="form-control form-control-sm" @bind="_attrDataSourceRef" placeholder="Tag path" /> <input type="text" class="form-control" @bind="_attrDataSourceRef" placeholder="Tag path" />
</div> </div>
<div class="col-md-1"> <div class="col-12">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_attrIsLocked" id="attrLocked" /> <input class="form-check-input" type="checkbox" @bind="_attrIsLocked" id="attrLocked" />
<label class="form-check-label small" for="attrLocked">Locked</label> <label class="form-check-label" for="attrLocked">Locked</label>
</div> </div>
</div> </div>
<div class="col-md-3"> @if (_attrFormError != null)
<button class="btn btn-success btn-sm me-1" @onclick="AddAttribute">Add</button> {
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showAttrForm = false">Cancel</button> <div class="col-12"><div class="text-danger small">@_attrFormError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="() => _showAttrForm = false">Cancel</button>
<button class="btn btn-success" @onclick="AddAttribute">Add</button>
</div> </div>
</div> </div>
@if (_attrFormError != null) { <div class="text-danger small mt-1">@_attrFormError</div> }
</div> </div>
</div> </div>
} }
@@ -437,7 +466,7 @@
<th>Value</th> <th>Value</th>
<th>Data Source</th> <th>Data Source</th>
<th>Lock</th> <th>Lock</th>
<th style="width: 80px;">Actions</th> <th style="width: 60px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -451,16 +480,26 @@
<td> <td>
@if (attr.IsLocked) @if (attr.IsLocked)
{ {
<span class="badge bg-danger" title="Locked">L</span> <span class="badge bg-danger" aria-label="Locked">Locked</span>
} }
else else
{ {
<span class="badge bg-light text-dark" title="Unlocked">U</span> <span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
} }
</td> </td>
<td> <td>
<button class="btn btn-outline-danger btn-sm py-0 px-1" <div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {attr.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteAttribute(attr)">Delete</button> @onclick="() => DeleteAttribute(attr)">Delete</button>
</li>
</ul>
</div>
</td> </td>
</tr> </tr>
} }
@@ -472,48 +511,52 @@
private RenderFragment RenderAlarmsTab() => __builder => private RenderFragment RenderAlarmsTab() => __builder =>
{ {
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Alarms</h6> <h5 class="mb-0">Alarms</h5>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAlarmForm = true; _alarmFormError = null; _alarmName = string.Empty; _alarmPriority = 500; _alarmTriggerConfig = null; _alarmIsLocked = false; }">Add Alarm</button> <button class="btn btn-primary btn-sm" @onclick="() => { _showAlarmForm = true; _alarmFormError = null; _alarmName = string.Empty; _alarmPriority = 500; _alarmTriggerConfig = null; _alarmIsLocked = false; }">Add Alarm</button>
</div> </div>
@if (_showAlarmForm) @if (_showAlarmForm)
{ {
<div class="card mb-2"> <div class="card mb-3">
<div class="card-header">Add Alarm</div>
<div class="card-body"> <div class="card-body">
<div class="row g-2 align-items-end"> <div class="row g-3">
<div class="col-md-2"> <div class="col-12">
<label class="form-label small">Name</label> <label class="form-label">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_alarmName" /> <input type="text" class="form-control" @bind="_alarmName" />
</div> </div>
<div class="col-md-2"> <div class="col-12">
<label class="form-label small">Trigger Type</label> <label class="form-label">Trigger Type</label>
<select class="form-select form-select-sm" @bind="_alarmTriggerType"> <select class="form-select" @bind="_alarmTriggerType">
@foreach (var tt in Enum.GetValues<AlarmTriggerType>()) @foreach (var tt in Enum.GetValues<AlarmTriggerType>())
{ {
<option value="@tt">@tt</option> <option value="@tt">@tt</option>
} }
</select> </select>
</div> </div>
<div class="col-md-1"> <div class="col-12">
<label class="form-label small">Priority</label> <label class="form-label">Priority</label>
<input type="number" class="form-control form-control-sm" @bind="_alarmPriority" min="0" max="1000" /> <input type="number" class="form-control" @bind="_alarmPriority" min="0" max="1000" />
</div> </div>
<div class="col-md-3"> <div class="col-12">
<label class="form-label small">Trigger Config (JSON)</label> <label class="form-label">Trigger Config (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_alarmTriggerConfig" /> <input type="text" class="form-control" @bind="_alarmTriggerConfig" />
</div> </div>
<div class="col-md-1"> <div class="col-12">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_alarmIsLocked" id="alarmLocked" /> <input class="form-check-input" type="checkbox" @bind="_alarmIsLocked" id="alarmLocked" />
<label class="form-check-label small" for="alarmLocked">Locked</label> <label class="form-check-label" for="alarmLocked">Locked</label>
</div> </div>
</div> </div>
<div class="col-md-3"> @if (_alarmFormError != null)
<button class="btn btn-success btn-sm me-1" @onclick="AddAlarm">Add</button> {
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showAlarmForm = false">Cancel</button> <div class="col-12"><div class="text-danger small">@_alarmFormError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="() => _showAlarmForm = false">Cancel</button>
<button class="btn btn-success" @onclick="AddAlarm">Add</button>
</div> </div>
</div> </div>
@if (_alarmFormError != null) { <div class="text-danger small mt-1">@_alarmFormError</div> }
</div> </div>
</div> </div>
} }
@@ -526,7 +569,7 @@
<th>Priority</th> <th>Priority</th>
<th>Config</th> <th>Config</th>
<th>Lock</th> <th>Lock</th>
<th style="width: 80px;">Actions</th> <th style="width: 60px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -538,12 +581,28 @@
<td>@alarm.PriorityLevel</td> <td>@alarm.PriorityLevel</td>
<td class="small text-muted text-truncate" style="max-width: 200px;">@(alarm.TriggerConfiguration ?? "—")</td> <td class="small text-muted text-truncate" style="max-width: 200px;">@(alarm.TriggerConfiguration ?? "—")</td>
<td> <td>
@if (alarm.IsLocked) { <span class="badge bg-danger">L</span> } @if (alarm.IsLocked)
else { <span class="badge bg-light text-dark">U</span> } {
<span class="badge bg-danger" aria-label="Locked">Locked</span>
}
else
{
<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
}
</td> </td>
<td> <td>
<button class="btn btn-outline-danger btn-sm py-0 px-1" <div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {alarm.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteAlarm(alarm)">Delete</button> @onclick="() => DeleteAlarm(alarm)">Delete</button>
</li>
</ul>
</div>
</td> </td>
</tr> </tr>
} }
@@ -555,44 +614,48 @@
private RenderFragment RenderScriptsTab() => __builder => private RenderFragment RenderScriptsTab() => __builder =>
{ {
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Scripts</h6> <h5 class="mb-0">Scripts</h5>
<button class="btn btn-primary btn-sm" @onclick="() => { _showScriptForm = true; _scriptFormError = null; _scriptName = string.Empty; _scriptCode = string.Empty; _scriptTriggerType = null; _scriptTriggerConfig = null; _scriptIsLocked = false; }">Add Script</button> <button class="btn btn-primary btn-sm" @onclick="() => { _showScriptForm = true; _scriptFormError = null; _scriptName = string.Empty; _scriptCode = string.Empty; _scriptTriggerType = null; _scriptTriggerConfig = null; _scriptIsLocked = false; }">Add Script</button>
</div> </div>
@if (_showScriptForm) @if (_showScriptForm)
{ {
<div class="card mb-2"> <div class="card mb-3">
<div class="card-header">Add Script</div>
<div class="card-body"> <div class="card-body">
<div class="row g-2"> <div class="row g-3">
<div class="col-md-3"> <div class="col-12">
<label class="form-label small">Name</label> <label class="form-label">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptName" /> <input type="text" class="form-control" @bind="_scriptName" />
</div> </div>
<div class="col-md-2"> <div class="col-12">
<label class="form-label small">Trigger Type</label> <label class="form-label">Trigger Type</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" /> <input type="text" class="form-control" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
</div> </div>
<div class="col-md-3"> <div class="col-12">
<label class="form-label small">Trigger Config (JSON)</label> <label class="form-label">Trigger Config (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptTriggerConfig" /> <input type="text" class="form-control" @bind="_scriptTriggerConfig" />
</div> </div>
<div class="col-md-1"> <div class="col-12">
<div class="form-check mt-4"> <div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" /> <input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
<label class="form-check-label small" for="scriptLocked">Locked</label> <label class="form-check-label" for="scriptLocked">Locked</label>
</div> </div>
</div> </div>
<div class="col-12">
<label class="form-label">Code</label>
<textarea class="form-control font-monospace" rows="10" @bind="_scriptCode"
style="font-size: 0.85rem;"></textarea>
</div> </div>
<div class="mt-2"> @if (_scriptFormError != null)
<label class="form-label small">Code</label> {
<textarea class="form-control form-control-sm font-monospace" rows="6" @bind="_scriptCode" <div class="col-12"><div class="text-danger small">@_scriptFormError</div></div>
style="font-size: 0.8rem;"></textarea> }
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="() => _showScriptForm = false">Cancel</button>
<button class="btn btn-success" @onclick="AddScript">Add</button>
</div> </div>
<div class="mt-2">
<button class="btn btn-success btn-sm me-1" @onclick="AddScript">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showScriptForm = false">Cancel</button>
</div> </div>
@if (_scriptFormError != null) { <div class="text-danger small mt-1">@_scriptFormError</div> }
</div> </div>
</div> </div>
} }
@@ -604,7 +667,7 @@
<th>Trigger</th> <th>Trigger</th>
<th>Code (preview)</th> <th>Code (preview)</th>
<th>Lock</th> <th>Lock</th>
<th style="width: 80px;">Actions</th> <th style="width: 60px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -613,14 +676,32 @@
<tr> <tr>
<td>@script.Name</td> <td>@script.Name</td>
<td class="small">@(script.TriggerType ?? "—")</td> <td class="small">@(script.TriggerType ?? "—")</td>
<td class="small text-muted text-truncate font-monospace" style="max-width: 300px;">@script.Code[..Math.Min(80, script.Code.Length)]@(script.Code.Length > 80 ? "..." : "")</td> <td class="small text-muted text-truncate font-monospace"
style="max-width: 300px;"
title="@script.Code">@script.Code[..Math.Min(80, script.Code.Length)]@(script.Code.Length > 80 ? "..." : "")</td>
<td> <td>
@if (script.IsLocked) { <span class="badge bg-danger">L</span> } @if (script.IsLocked)
else { <span class="badge bg-light text-dark">U</span> } {
<span class="badge bg-danger" aria-label="Locked">Locked</span>
}
else
{
<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
}
</td> </td>
<td> <td>
<button class="btn btn-outline-danger btn-sm py-0 px-1" <div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {script.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteScript(script)">Delete</button> @onclick="() => DeleteScript(script)">Delete</button>
</li>
</ul>
</div>
</td> </td>
</tr> </tr>
} }
@@ -632,22 +713,23 @@
private RenderFragment RenderCompositionsTab() => __builder => private RenderFragment RenderCompositionsTab() => __builder =>
{ {
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Compositions</h6> <h5 class="mb-0">Compositions</h5>
<button class="btn btn-primary btn-sm" @onclick="() => { _showCompForm = true; _compFormError = null; _compInstanceName = string.Empty; _compComposedTemplateId = 0; }">Add Composition</button> <button class="btn btn-primary btn-sm" @onclick="() => { _showCompForm = true; _compFormError = null; _compInstanceName = string.Empty; _compComposedTemplateId = 0; }">Add Composition</button>
</div> </div>
@if (_showCompForm) @if (_showCompForm)
{ {
<div class="card mb-2"> <div class="card mb-3">
<div class="card-header">Add Composition</div>
<div class="card-body"> <div class="card-body">
<div class="row g-2 align-items-end"> <div class="row g-3">
<div class="col-md-3"> <div class="col-12">
<label class="form-label small">Instance Name</label> <label class="form-label">Instance Name</label>
<input type="text" class="form-control form-control-sm" @bind="_compInstanceName" /> <input type="text" class="form-control" @bind="_compInstanceName" />
</div> </div>
<div class="col-md-4"> <div class="col-12">
<label class="form-label small">Composed Template</label> <label class="form-label">Composed Template</label>
<select class="form-select form-select-sm" @bind="_compComposedTemplateId"> <select class="form-select" @bind="_compComposedTemplateId">
<option value="0">Select template...</option> <option value="0">Select template...</option>
@foreach (var t in _templates.Where(t => _selectedTemplate == null || t.Id != _selectedTemplate.Id)) @foreach (var t in _templates.Where(t => _selectedTemplate == null || t.Id != _selectedTemplate.Id))
{ {
@@ -655,12 +737,15 @@
} }
</select> </select>
</div> </div>
<div class="col-md-3"> @if (_compFormError != null)
<button class="btn btn-success btn-sm me-1" @onclick="AddComposition">Add</button> {
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showCompForm = false">Cancel</button> <div class="col-12"><div class="text-danger small">@_compFormError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="() => _showCompForm = false">Cancel</button>
<button class="btn btn-success" @onclick="AddComposition">Add</button>
</div> </div>
</div> </div>
@if (_compFormError != null) { <div class="text-danger small mt-1">@_compFormError</div> }
</div> </div>
</div> </div>
} }
@@ -670,7 +755,7 @@
<tr> <tr>
<th>Instance Name</th> <th>Instance Name</th>
<th>Composed Template</th> <th>Composed Template</th>
<th style="width: 80px;">Actions</th> <th style="width: 60px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -680,8 +765,18 @@
<td><code>@comp.InstanceName</code></td> <td><code>@comp.InstanceName</code></td>
<td>@(_templates.FirstOrDefault(t => t.Id == comp.ComposedTemplateId)?.Name ?? $"#{comp.ComposedTemplateId}")</td> <td>@(_templates.FirstOrDefault(t => t.Id == comp.ComposedTemplateId)?.Name ?? $"#{comp.ComposedTemplateId}")</td>
<td> <td>
<button class="btn btn-outline-danger btn-sm py-0 px-1" <div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {comp.InstanceName}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteComposition(comp)">Delete</button> @onclick="() => DeleteComposition(comp)">Delete</button>
</li>
</ul>
</div>
</td> </td>
</tr> </tr>
} }
@@ -13,7 +13,7 @@
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" /> <ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
<RenameFolderDialog @bind-IsVisible="_showRenameFolderDialog" <RenameFolderDialog @bind-IsVisible="_showRenameFolderDialog"
FolderId="_renameFolderId" FolderId="_renameFolderId"
@@ -50,14 +50,30 @@
} }
else else
{ {
<h6 class="mb-2">Templates</h6> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="btn-group btn-group-sm mb-2"> <h4 class="mb-0">Templates</h4>
<button class="btn btn-outline-secondary" title="New folder at root" <div class="d-flex gap-2">
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
data-bs-toggle="dropdown" aria-expanded="false">
Bulk actions
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @onclick="() => _tree.ExpandAll()">Expand all folders</button>
</li>
<li>
<button class="dropdown-item" @onclick="() => _tree.CollapseAll()">Collapse all folders</button>
</li>
</ul>
</div>
<button class="btn btn-outline-secondary btn-sm"
title="New folder at root"
@onclick="() => OpenNewFolderDialog(null)">+ Folder</button> @onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
<button class="btn btn-outline-secondary" title="New template at root" <button class="btn btn-primary btn-sm"
title="New template at root"
@onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>+ Template</button> @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>+ Template</button>
<button class="btn btn-outline-secondary" @onclick="() => _tree.ExpandAll()">Expand</button> </div>
<button class="btn btn-outline-secondary" @onclick="() => _tree.CollapseAll()">Collapse</button>
</div> </div>
<div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;"> <div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;">
@@ -230,21 +246,61 @@
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span> <span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span>
</span> </span>
} }
@RenderNodeKebab(node)
break; break;
case TmplNodeKind.Template: case TmplNodeKind.Template:
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span> <span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")" <span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Label">@node.Label</span> title="@node.Label">@node.Label</span>
@RenderNodeKebab(node)
break; break;
case TmplNodeKind.Composition: case TmplNodeKind.Composition:
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span> <span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
<span class="tv-label" title="@node.Label">@node.Label</span> <span class="tv-label" title="@node.Label">@node.Label</span>
@RenderNodeKebab(node)
break; break;
} }
}; };
private RenderFragment RenderNodeKebab(TmplNode node) => __builder =>
{
<span class="tv-kebab dropdown ms-auto" @onclick:stopPropagation="true">
<button type="button"
class="btn btn-link btn-sm p-0 px-1 text-secondary tv-kebab-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {node.Label}")">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
@switch (node.Kind)
{
case TmplNodeKind.Folder:
<li><button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.EntityId)">New Folder</button></li>
<li><button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.EntityId}")'>New Template</button></li>
<li><button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.EntityId, node.Label)">Rename</button></li>
<li><button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.EntityId, node.Label)">Move to Folder…</button></li>
<li><hr class="dropdown-divider" /></li>
<li><button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.EntityId, node.Label)">Delete</button></li>
break;
case TmplNodeKind.Template:
<li><button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.EntityId}")'>New Derived Template</button></li>
<li><button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.EntityId, node.Label)">Move to Folder…</button></li>
<li><hr class="dropdown-divider" /></li>
<li><button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template!)">Delete</button></li>
break;
case TmplNodeKind.Composition:
<li><button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{node.Composition!.ComposedTemplateId}")'>Open composed template</button></li>
break;
}
</ul>
</span>
};
private void OnTreeNodeSelected(object? key) private void OnTreeNodeSelected(object? key)
{ {
if (key is not string s) return; if (key is not string s) return;
@@ -2,8 +2,8 @@
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
<div class="container" style="max-width: 400px; margin-top: 10vh;"> <div class="d-flex align-items-center justify-content-center min-vh-100">
<div class="card shadow-sm"> <div class="card shadow-sm" style="max-width: 400px; width: 100%;">
<div class="card-body p-4"> <div class="card-body p-4">
<h4 class="card-title mb-4 text-center">ScadaLink</h4> <h4 class="card-title mb-4 text-center">ScadaLink</h4>
@@ -25,9 +25,9 @@
</div> </div>
<button type="submit" class="btn btn-primary w-100">Sign In</button> <button type="submit" class="btn btn-primary w-100">Sign In</button>
</form> </form>
<p class="text-center text-muted mt-3 small mb-0">Authenticate with your organization's LDAP credentials.</p>
</div> </div>
</div> </div>
<p class="text-center text-muted mt-3 small">Authenticate with your organization's LDAP credentials.</p>
</div> </div>
@code { @code {
@@ -4,38 +4,65 @@
@using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ICentralUiRepository CentralUiRepository @inject ICentralUiRepository CentralUiRepository
@inject IJSRuntime JS
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<h4 class="mb-3">Audit Log</h4> <h4 class="mb-3">Audit Log</h4>
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<div class="row mb-3 g-2"> <div class="row mb-3 g-2 align-items-end">
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label small">User</label> <label class="form-label small" for="audit-filter-user">User</label>
<input type="text" class="form-control form-control-sm" @bind="_filterUser" placeholder="Username" /> <input id="audit-filter-user"
type="text"
class="form-control form-control-sm"
aria-label="User"
@bind="_filterUser"
placeholder="Username" />
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label small">Entity Type</label> <label class="form-label small" for="audit-filter-entity-type">Entity Type</label>
<input type="text" class="form-control form-control-sm" @bind="_filterEntityType" placeholder="e.g. Template" /> <input id="audit-filter-entity-type"
type="text"
class="form-control form-control-sm"
aria-label="Entity type"
@bind="_filterEntityType"
placeholder="e.g. Template" />
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label small">Action</label> <label class="form-label small" for="audit-filter-action">Action</label>
<input type="text" class="form-control form-control-sm" @bind="_filterAction" placeholder="e.g. Create" /> <input id="audit-filter-action"
type="text"
class="form-control form-control-sm"
aria-label="Action"
@bind="_filterAction"
placeholder="e.g. Create" />
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label small">From</label> <label class="form-label small" for="audit-filter-from">From</label>
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterFrom" /> <input id="audit-filter-from"
type="datetime-local"
class="form-control form-control-sm"
aria-label="From timestamp"
@bind="_filterFrom" />
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label small">To</label> <label class="form-label small" for="audit-filter-to">To</label>
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterTo" /> <input id="audit-filter-to"
type="datetime-local"
class="form-control form-control-sm"
aria-label="To timestamp"
@bind="_filterTo" />
</div> </div>
<div class="col-md-2 d-flex align-items-end"> <div class="col-md-2 d-flex gap-1">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_searching"> <button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_searching">
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> } @if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search Search
</button> </button>
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters" disabled="@_searching">
Clear filters
</button>
</div> </div>
</div> </div>
@@ -65,32 +92,62 @@
} }
@foreach (var entry in _entries) @foreach (var entry in _entries)
{ {
var entityIdShort = entry.EntityId is { Length: > 0 }
? entry.EntityId[..Math.Min(12, entry.EntityId.Length)]
: "";
var hasState = !string.IsNullOrWhiteSpace(entry.AfterStateJson);
var isLarge = hasState && entry.AfterStateJson!.Length > 1024;
<tr> <tr>
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td> <td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
<td class="small">@entry.User</td> <td class="small">@entry.User</td>
<td><span class="badge @GetActionBadge(entry.Action)">@entry.Action</span></td> <td><span class="badge @GetActionBadge(entry.Action)">@entry.Action</span></td>
<td class="small">@entry.EntityType</td> <td class="small">@entry.EntityType</td>
<td class="small"><code>@entry.EntityId</code></td> <td class="small">
@if (!string.IsNullOrEmpty(entry.EntityId))
{
<code>@entityIdShort…</code>
<button class="btn btn-link btn-sm p-0 ms-1"
@onclick="() => CopyAsync(entry.EntityId)"
title="Copy entity ID"
aria-label="Copy entity ID @entry.EntityId">📋</button>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="small">@entry.EntityName</td> <td class="small">@entry.EntityName</td>
<td> <td>
@if (!string.IsNullOrWhiteSpace(entry.AfterStateJson)) @if (hasState)
{
if (isLarge)
{ {
<button class="btn btn-outline-info btn-sm py-0 px-1" <button class="btn btn-outline-info btn-sm py-0 px-1"
@onclick="() => ToggleStateView(entry.Id)"> @onclick="() => ShowStateModal(entry)"
aria-label="Open state details in modal for audit entry @entry.Id">
View in modal
</button>
}
else
{
<button class="btn btn-outline-info btn-sm py-0 px-1"
@onclick="() => ToggleStateView(entry.Id)"
aria-label="Toggle state details for audit entry @entry.Id">
@(_expandedEntryId == entry.Id ? "Hide" : "View") @(_expandedEntryId == entry.Id ? "Hide" : "View")
</button> </button>
} }
}
else else
{ {
<span class="text-muted small">—</span> <span class="text-muted small">—</span>
} }
</td> </td>
</tr> </tr>
@if (_expandedEntryId == entry.Id && !string.IsNullOrWhiteSpace(entry.AfterStateJson)) @if (hasState && !isLarge && _expandedEntryId == entry.Id)
{ {
<tr> <tr>
<td colspan="7"> <td colspan="7">
<pre class="bg-light p-2 rounded small mb-0" style="max-height: 200px; overflow: auto;">@FormatJson(entry.AfterStateJson)</pre> <pre class="bg-light p-2 rounded small mb-0" style="max-height: 200px; overflow: auto;">@FormatJson(entry.AfterStateJson!)</pre>
</td> </td>
</tr> </tr>
} }
@@ -99,10 +156,33 @@
</table> </table>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Page @_page of @((_totalCount + _pageSize - 1) / _pageSize) (@_totalCount total)</span> <span class="text-muted small">Page @_page of @TotalPages (@_totalCount total)</span>
<div> <div>
<button class="btn btn-outline-secondary btn-sm me-1" @onclick="PrevPage" disabled="@(_page <= 1)">Previous</button> <button class="btn btn-outline-secondary btn-sm me-1" @onclick="PrevPage" disabled="@(_page <= 1)">Previous</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="NextPage" disabled="@(_entries.Count < _pageSize)">Next</button> <button class="btn btn-outline-secondary btn-sm" @onclick="NextPage" disabled="@(!HasMore)">Next</button>
</div>
</div>
}
@if (_modalEntry != null)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Audit entry @_modalEntry.Id — @_modalEntry.EntityType state
</h5>
<button type="button" class="btn-close" @onclick="CloseStateModal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre class="bg-light p-2 rounded small mb-0">@FormatJson(_modalEntry.AfterStateJson!)</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseStateModal">Close</button>
</div>
</div>
</div> </div>
</div> </div>
} }
@@ -122,15 +202,30 @@
private bool _searching; private bool _searching;
private string? _errorMessage; private string? _errorMessage;
private int? _expandedEntryId; private int? _expandedEntryId;
private AuditLogEntry? _modalEntry;
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private int TotalPages => _pageSize > 0 ? Math.Max(1, (_totalCount + _pageSize - 1) / _pageSize) : 1;
private bool HasMore => _page * _pageSize < _totalCount;
private async Task Search() private async Task Search()
{ {
_page = 1; _page = 1;
await FetchPage(); await FetchPage();
} }
private async Task ClearFilters()
{
_filterUser = null;
_filterEntityType = null;
_filterAction = null;
_filterFrom = null;
_filterTo = null;
_page = 1;
await FetchPage();
}
private async Task PrevPage() { _page--; await FetchPage(); } private async Task PrevPage() { _page--; await FetchPage(); }
private async Task NextPage() { _page++; await FetchPage(); } private async Task NextPage() { _page++; await FetchPage(); }
@@ -164,6 +259,29 @@
_expandedEntryId = _expandedEntryId == entryId ? null : entryId; _expandedEntryId = _expandedEntryId == entryId ? null : entryId;
} }
private void ShowStateModal(AuditLogEntry entry)
{
_modalEntry = entry;
}
private void CloseStateModal()
{
_modalEntry = null;
}
private async Task CopyAsync(string text)
{
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
_toast.ShowSuccess("Copied to clipboard.");
}
catch
{
_toast.ShowError("Copy failed.");
}
}
private static string GetActionBadge(string action) => action switch private static string GetActionBadge(string action) => action switch
{ {
"Create" => "bg-success", "Create" => "bg-success",
@@ -8,14 +8,25 @@
@inject CommunicationService CommunicationService @inject CommunicationService CommunicationService
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<h4 class="mb-3">Site Event Logs</h4> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Site Event Logs</h4>
<button class="btn btn-outline-secondary btn-sm"
type="button"
data-bs-toggle="collapse"
data-bs-target="#event-logs-filters"
aria-expanded="true"
aria-controls="event-logs-filters">
Filter options (@ActiveFilterCount active)
</button>
</div>
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<div class="row mb-3 g-2"> <div class="collapse show" id="event-logs-filters">
<div class="row mb-3 g-2 align-items-end">
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label small">Site</label> <label class="form-label small" for="filter-site">Site</label>
<select class="form-select form-select-sm" @bind="_selectedSiteId"> <select id="filter-site" class="form-select form-select-sm" aria-label="Site" @bind="_selectedSiteId">
<option value="">Select site...</option> <option value="">Select site...</option>
@foreach (var site in _sites) @foreach (var site in _sites)
{ {
@@ -24,12 +35,20 @@
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label small">Event Type</label> <label class="form-label small" for="filter-event-type">Event Type</label>
<input type="text" class="form-control form-control-sm" @bind="_filterEventType" placeholder="e.g. ScriptError" /> <input id="filter-event-type"
type="text"
class="form-control form-control-sm"
aria-label="Event type"
@bind="_filterEventType"
placeholder="e.g. ScriptError" />
</div> </div>
<div class="col-md-1"> <div class="col-md-1">
<label class="form-label small">Severity</label> <label class="form-label small" for="filter-severity">Severity</label>
<select class="form-select form-select-sm" @bind="_filterSeverity"> <select id="filter-severity"
class="form-select form-select-sm"
aria-label="Severity"
@bind="_filterSeverity">
<option value="">All</option> <option value="">All</option>
<option>Info</option> <option>Info</option>
<option>Warning</option> <option>Warning</option>
@@ -37,28 +56,46 @@
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label small">From</label> <label class="form-label small" for="filter-from">From</label>
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterFrom" /> <input id="filter-from"
type="datetime-local"
class="form-control form-control-sm"
aria-label="From timestamp"
@bind="_filterFrom" />
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label small">To</label> <label class="form-label small" for="filter-to">To</label>
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterTo" /> <input id="filter-to"
type="datetime-local"
class="form-control form-control-sm"
aria-label="To timestamp"
@bind="_filterTo" />
</div> </div>
<div class="col-md-1"> <div class="col-md-1">
<label class="form-label small">Keyword</label> <label class="form-label small" for="filter-keyword">Message contains</label>
<input type="text" class="form-control form-control-sm" @bind="_filterKeyword" /> <input id="filter-keyword"
type="text"
class="form-control form-control-sm"
aria-label="Message contains"
@bind="_filterKeyword" />
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label small">Instance</label> <label class="form-label small" for="filter-instance">Instance</label>
<input type="text" class="form-control form-control-sm" @bind="_filterInstanceName" placeholder="Instance name" /> <input id="filter-instance"
type="text"
class="form-control form-control-sm"
aria-label="Instance name"
@bind="_filterInstanceName"
placeholder="Instance name" />
</div> </div>
<div class="col-md-1 d-flex align-items-end"> <div class="col-md-12 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)"> <button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> } @if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search Search
</button> </button>
</div> </div>
</div> </div>
</div>
@if (_errorMessage != null) @if (_errorMessage != null)
{ {
@@ -70,6 +107,7 @@
<table class="table table-sm table-striped table-hover"> <table class="table table-sm table-striped table-hover">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th style="width: 1%;"></th>
<th>Timestamp</th> <th>Timestamp</th>
<th>Type</th> <th>Type</th>
<th>Severity</th> <th>Severity</th>
@@ -81,28 +119,59 @@
<tbody> <tbody>
@if (_entries.Count == 0) @if (_entries.Count == 0)
{ {
<tr><td colspan="6" class="text-muted text-center">No events found.</td></tr> <tr><td colspan="7" class="text-muted text-center">No events found.</td></tr>
} }
@foreach (var entry in _entries) @for (int i = 0; i < _entries.Count; i++)
{ {
<tr class="@(entry.Severity == "Error" ? "table-danger" : entry.Severity == "Warning" ? "table-warning" : "")"> var idx = i;
var entry = _entries[idx];
var rowClass = entry.Severity == "Error" ? "table-danger"
: entry.Severity == "Warning" ? "table-warning"
: "";
var expanded = _expandedRows.Contains(idx);
<tr class="@rowClass">
<td>
<button class="btn btn-link btn-sm p-0"
@onclick="() => ToggleRow(idx)"
aria-label="@(expanded ? "Hide full message" : "View full message")">
@(expanded ? "Hide" : "View")
</button>
</td>
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td> <td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
<td class="small">@entry.EventType</td> <td class="small">@entry.EventType</td>
<td><span class="badge @GetSeverityBadge(entry.Severity)">@entry.Severity</span></td> <td>
<span class="badge @GetSeverityBadge(entry.Severity)" aria-label="Severity: @entry.Severity">
@SeverityGlyph(entry.Severity) @entry.Severity
</span>
</td>
<td class="small">@(entry.InstanceId ?? "—")</td> <td class="small">@(entry.InstanceId ?? "—")</td>
<td class="small">@entry.Source</td> <td class="small">@entry.Source</td>
<td class="small">@entry.Message</td> <td class="small text-truncate" style="max-width: 380px;">@entry.Message</td>
</tr> </tr>
@if (expanded)
{
<tr class="@rowClass">
<td colspan="7">
<pre class="small mb-0">@entry.Message</pre>
</td>
</tr>
}
} }
</tbody> </tbody>
</table> </table>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">@_entries.Count entries loaded</span> <span class="text-muted small">Showing @_entries.Count entries</span>
<div>
@if (_hasMore) @if (_hasMore)
{ {
<button class="btn btn-outline-primary btn-sm" @onclick="LoadMore" disabled="@_searching">Load More</button> <button class="btn btn-outline-primary btn-sm" @onclick="LoadMore" disabled="@_searching">Load more</button>
} }
else if (_entries.Count > 0)
{
<span class="text-muted small">End of results</span>
}
</div>
</div> </div>
} }
</div> </div>
@@ -123,6 +192,23 @@
private bool _searching; private bool _searching;
private string? _errorMessage; private string? _errorMessage;
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private readonly HashSet<int> _expandedRows = new();
private int ActiveFilterCount
{
get
{
var n = 0;
if (!string.IsNullOrEmpty(_selectedSiteId)) n++;
if (!string.IsNullOrWhiteSpace(_filterEventType)) n++;
if (!string.IsNullOrEmpty(_filterSeverity)) n++;
if (_filterFrom.HasValue) n++;
if (_filterTo.HasValue) n++;
if (!string.IsNullOrWhiteSpace(_filterKeyword)) n++;
if (!string.IsNullOrWhiteSpace(_filterInstanceName)) n++;
return n;
}
}
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -133,11 +219,20 @@
{ {
_entries = new(); _entries = new();
_continuationToken = null; _continuationToken = null;
_expandedRows.Clear();
await FetchPage(); await FetchPage();
} }
private async Task LoadMore() => await FetchPage(); private async Task LoadMore() => await FetchPage();
private void ToggleRow(int idx)
{
if (!_expandedRows.Add(idx))
{
_expandedRows.Remove(idx);
}
}
private async Task FetchPage() private async Task FetchPage()
{ {
_searching = true; _searching = true;
@@ -185,4 +280,12 @@
"Info" => "bg-info text-dark", "Info" => "bg-info text-dark",
_ => "bg-secondary" _ => "bg-secondary"
}; };
private static string SeverityGlyph(string severity) => severity switch
{
"Error" => "⛔",
"Warning" => "⚠",
"Info" => "",
_ => "•"
};
} }
@@ -24,36 +24,28 @@
else else
{ {
@* Overview cards *@ @* Overview cards *@
<div class="row mb-3"> <div class="row g-3 mb-3">
<div class="col-md-3"> <div class="col-lg-4 col-md-6 col-12">
<div class="card border-success"> <div class="card border-success h-100">
<div class="card-body text-center"> <div class="card-body text-center">
<h3 class="mb-0 text-success">@_siteStates.Values.Count(s => s.IsOnline)</h3> <h3 class="mb-0 text-success">@_siteStates.Values.Count(s => s.IsOnline)</h3>
<small class="text-muted">Sites Online</small> <small class="text-muted">Sites Online</small>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-lg-4 col-md-6 col-12">
<div class="card border-danger"> <div class="card border-danger h-100">
<div class="card-body text-center"> <div class="card-body text-center">
<h3 class="mb-0 text-danger">@_siteStates.Values.Count(s => !s.IsOnline)</h3> <h3 class="mb-0 text-danger">@_siteStates.Values.Count(s => !s.IsOnline)</h3>
<small class="text-muted">Sites Offline</small> <small class="text-muted">Sites Offline</small>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-lg-4 col-md-6 col-12">
<div class="card"> <div class="card border-warning h-100">
<div class="card-body text-center"> <div class="card-body text-center">
<h3 class="mb-0">@_siteStates.Count</h3> <h3 class="mb-0 text-warning">@_siteStates.Values.Count(SiteHasActiveErrors)</h3>
<small class="text-muted">Total Sites</small> <small class="text-muted">Sites with active errors</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body text-center">
<h3 class="mb-0">@_siteStates.Values.Sum(s => s.LatestReport?.ScriptErrorCount ?? 0)</h3>
<small class="text-muted">Total Script Errors</small>
</div> </div>
</div> </div>
</div> </div>
@@ -63,21 +55,22 @@
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key)) @foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key))
{ {
var siteName = GetSiteName(siteId); var siteName = GetSiteName(siteId);
var detailsCollapseId = $"site-details-{siteId}";
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center py-2"> <div class="card-header d-flex justify-content-between align-items-center py-2">
<div> <div>
@if (state.IsOnline) @if (state.IsOnline)
{ {
<span class="badge bg-success me-2">Online</span> <span class="badge bg-success me-2" aria-label="State: Online">@OnlineGlyph Online</span>
} }
else else
{ {
<span class="badge bg-danger me-2">Offline</span> <span class="badge bg-danger me-2" aria-label="State: Offline">@OfflineGlyph Offline</span>
} }
<strong class="fs-5">@siteName (@siteId)</strong> <strong class="fs-5">@siteName (@siteId)</strong>
</div> </div>
<small class="text-muted"> <small class="text-muted">
Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" /> | Seq: @state.LastSequenceNumber
</small> </small>
</div> </div>
<div class="card-body p-3"> <div class="card-body p-3">
@@ -86,7 +79,7 @@
var report = state.LatestReport; var report = state.LatestReport;
<div class="row g-3"> <div class="row g-3">
@* Column 1: Nodes *@ @* Column 1: Nodes *@
<div class="col-md-3"> <div class="col-md-6">
<h6 class="text-muted mb-2 border-bottom pb-1">Nodes</h6> <h6 class="text-muted mb-2 border-bottom pb-1">Nodes</h6>
<table class="table table-sm table-borderless mb-0"> <table class="table table-sm table-borderless mb-0">
<tbody> <tbody>
@@ -96,8 +89,18 @@
{ {
<tr> <tr>
<td class="small">@node.Hostname</td> <td class="small">@node.Hostname</td>
<td><span class="badge @(node.IsOnline ? "bg-success" : "bg-danger")">@(node.IsOnline ? "Online" : "Offline")</span></td> <td>
<td><span class="badge @(node.Role == "Primary" ? "bg-primary" : "bg-secondary")">@node.Role</span></td> <span class="badge @(node.IsOnline ? "bg-success" : "bg-danger")"
aria-label="State: @(node.IsOnline ? "Online" : "Offline")">
@(node.IsOnline ? OnlineGlyph : OfflineGlyph) @(node.IsOnline ? "Online" : "Offline")
</span>
</td>
<td>
<span class="badge @(node.Role == "Primary" ? "bg-primary" : "bg-secondary")"
aria-label="State: @node.Role">
@(node.Role == "Primary" ? PrimaryGlyph : StandbyGlyph) @node.Role
</span>
</td>
</tr> </tr>
} }
} }
@@ -105,17 +108,36 @@
{ {
<tr> <tr>
<td class="small">@(report.NodeHostname != "" ? report.NodeHostname : "Node")</td> <td class="small">@(report.NodeHostname != "" ? report.NodeHostname : "Node")</td>
<td><span class="badge @(state.IsOnline ? "bg-success" : "bg-danger")">@(state.IsOnline ? "Online" : "Offline")</span></td> <td>
<td><span class="badge @(report.NodeRole == "Active" ? "bg-primary" : "bg-secondary")">@(report.NodeRole == "Active" ? "Primary" : "Standby")</span></td> <span class="badge @(state.IsOnline ? "bg-success" : "bg-danger")"
aria-label="State: @(state.IsOnline ? "Online" : "Offline")">
@(state.IsOnline ? OnlineGlyph : OfflineGlyph) @(state.IsOnline ? "Online" : "Offline")
</span>
</td>
<td>
@{
var roleLabel = report.NodeRole == "Active" ? "Primary" : "Standby";
}
<span class="badge @(report.NodeRole == "Active" ? "bg-primary" : "bg-secondary")"
aria-label="State: @roleLabel">
@(roleLabel == "Primary" ? PrimaryGlyph : StandbyGlyph) @roleLabel
</span>
</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </div>
@* Column 2: Data Connections *@ @* Column 2: Data Connections (collapsible) *@
<div class="col-md-3"> <div class="col-md-6">
<h6 class="text-muted mb-2 border-bottom pb-1">Data Connections</h6> <button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-conns")"
aria-expanded="false">
Data Connections (@report.DataConnectionStatuses.Count)
</button>
<div class="collapse" id="@($"{detailsCollapseId}-conns")">
@if (report.DataConnectionStatuses.Count == 0) @if (report.DataConnectionStatuses.Count == 0)
{ {
<span class="text-muted small">None</span> <span class="text-muted small">None</span>
@@ -154,9 +176,17 @@
} }
} }
</div> </div>
</div>
@* Column 3: Instances + Store-and-Forward *@ @* Column 3: Instances + Store-and-Forward (collapsible) *@
<div class="col-md-3"> <div class="col-md-6">
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-queues")"
aria-expanded="false">
Instances &amp; Queues
</button>
<div class="collapse" id="@($"{detailsCollapseId}-queues")">
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6> <h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
<table class="table table-sm table-borderless mb-0"> <table class="table table-sm table-borderless mb-0">
<tbody> <tbody>
@@ -191,9 +221,17 @@
} }
} }
</div> </div>
</div>
@* Column 4: Error Counts + Parked Messages *@ @* Column 4: Error Counts + Parked Messages (collapsible) *@
<div class="col-md-3"> <div class="col-md-6">
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-errors")"
aria-expanded="false">
Errors &amp; Parked Messages
</button>
<div class="collapse" id="@($"{detailsCollapseId}-errors")">
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6> <h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
<table class="table table-sm table-borderless mb-0"> <table class="table table-sm table-borderless mb-0">
<tbody> <tbody>
@@ -229,6 +267,7 @@
} }
</div> </div>
</div> </div>
</div>
} }
else else
{ {
@@ -241,11 +280,26 @@
</div> </div>
@code { @code {
// Shape-coded status glyphs to pair with badge colour.
private const string OnlineGlyph = "●"; // ●
private const string OfflineGlyph = "○"; // ○
private const string PrimaryGlyph = "▲"; // ▲
private const string StandbyGlyph = "△"; // △
private IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>(); private IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>();
private Dictionary<string, string> _siteNames = new(); private Dictionary<string, string> _siteNames = new();
private Timer? _refreshTimer; private Timer? _refreshTimer;
private int _autoRefreshSeconds = 10; private int _autoRefreshSeconds = 10;
private static bool SiteHasActiveErrors(SiteHealthState state)
{
var report = state.LatestReport;
if (report == null) return false;
return report.ScriptErrorCount > 0
|| report.AlarmEvaluationErrorCount > 0
|| report.DeadLetterCount > 0;
}
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// Load site names for display // Load site names for display
@@ -6,17 +6,18 @@
@using ScadaLink.Communication @using ScadaLink.Communication
@inject ISiteRepository SiteRepository @inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService @inject CommunicationService CommunicationService
@inject IJSRuntime JS
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<h4 class="mb-3">Parked Messages</h4> <h4 class="mb-3">Parked Messages</h4>
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" /> <ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
<div class="row mb-3 g-2"> <div class="row mb-3 g-2 align-items-end">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label small">Site</label> <label class="form-label small" for="pm-filter-site">Site</label>
<select class="form-select form-select-sm" @bind="_selectedSiteId"> <select id="pm-filter-site" class="form-select form-select-sm" aria-label="Site" @bind="_selectedSiteId">
<option value="">Select site...</option> <option value="">Select site...</option>
@foreach (var site in _sites) @foreach (var site in _sites)
{ {
@@ -24,10 +25,10 @@
} }
</select> </select>
</div> </div>
<div class="col-md-2 d-flex align-items-end"> <div class="col-md-2">
<button class="btn btn-primary btn-sm" @onclick="Search" <button class="btn btn-primary btn-sm" @onclick="Search"
disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)"> disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> } @if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Query Query
</button> </button>
</div> </div>
@@ -43,6 +44,7 @@
<table class="table table-sm table-striped table-hover"> <table class="table table-sm table-striped table-hover">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th style="width: 1%;"></th>
<th>Message ID</th> <th>Message ID</th>
<th>Target System</th> <th>Target System</th>
<th>Method</th> <th>Method</th>
@@ -56,27 +58,70 @@
<tbody> <tbody>
@if (_messages.Count == 0) @if (_messages.Count == 0)
{ {
<tr><td colspan="8" class="text-muted text-center">No parked messages.</td></tr> <tr><td colspan="9" class="text-muted text-center">No parked messages.</td></tr>
} }
@foreach (var msg in _messages) @for (int i = 0; i < _messages.Count; i++)
{ {
var idx = i;
var msg = _messages[idx];
var idShort = msg.MessageId[..Math.Min(12, msg.MessageId.Length)];
var expanded = _expandedRows.Contains(idx);
var retryActive = _actionInProgress && _activeMessageId == msg.MessageId && _activeAction == "Retry";
var discardActive = _actionInProgress && _activeMessageId == msg.MessageId && _activeAction == "Discard";
<tr> <tr>
<td class="small"><code>@msg.MessageId[..Math.Min(12, msg.MessageId.Length)]</code></td> <td>
<button class="btn btn-link btn-sm p-0"
@onclick="() => ToggleRow(idx)"
aria-label="@(expanded ? "Hide error details" : "View error details")">
@(expanded ? "Hide" : "View")
</button>
</td>
<td class="small">
<code class="small">@idShort…</code>
<button class="btn btn-link btn-sm p-0 ms-1"
@onclick="() => CopyAsync(msg.MessageId)"
title="Copy message ID"
aria-label="Copy message ID @msg.MessageId">📋</button>
</td>
<td class="small">@msg.TargetSystem</td> <td class="small">@msg.TargetSystem</td>
<td class="small">@msg.MethodName</td> <td class="small">@msg.MethodName</td>
<td class="small text-danger">@msg.ErrorMessage</td> <td class="small text-danger text-truncate" style="max-width: 320px;">@msg.ErrorMessage</td>
<td class="small text-center">@msg.AttemptCount</td> <td class="small text-center">@msg.AttemptCount</td>
<td class="small"><TimestampDisplay Value="@msg.OriginalTimestamp" /></td> <td class="small"><TimestampDisplay Value="@msg.OriginalTimestamp" /></td>
<td class="small"><TimestampDisplay Value="@msg.LastAttemptTimestamp" /></td> <td class="small"><TimestampDisplay Value="@msg.LastAttemptTimestamp" /></td>
<td> <td>
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1" <button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
@onclick="() => RetryMessage(msg)" disabled="@_actionInProgress" @onclick="() => RetryMessage(msg)"
title="Retry message (move back to pending)">Retry</button> disabled="@_actionInProgress"
title="Retry message (move back to pending)"
aria-label="Retry message @idShort">
@if (retryActive)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
Retry
</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" <button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DiscardMessage(msg)" disabled="@_actionInProgress" @onclick="() => DiscardMessage(msg)"
title="Permanently discard message">Discard</button> disabled="@_actionInProgress"
title="Permanently discard message"
aria-label="Discard message @idShort">
@if (discardActive)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
Discard
</button>
</td> </td>
</tr> </tr>
@if (expanded)
{
<tr>
<td colspan="9">
<pre class="small mb-0">@msg.ErrorMessage</pre>
</td>
</tr>
}
} }
</tbody> </tbody>
</table> </table>
@@ -105,8 +150,11 @@
private string? _errorMessage; private string? _errorMessage;
private bool _actionInProgress; private bool _actionInProgress;
private string? _activeMessageId;
private string? _activeAction;
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!; private ConfirmDialog _confirmDialog = default!;
private readonly HashSet<int> _expandedRows = new();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -116,12 +164,34 @@
private async Task Search() private async Task Search()
{ {
_pageNumber = 1; _pageNumber = 1;
_expandedRows.Clear();
await FetchPage(); await FetchPage();
} }
private async Task PrevPage() { _pageNumber--; await FetchPage(); } private async Task PrevPage() { _pageNumber--; await FetchPage(); }
private async Task NextPage() { _pageNumber++; await FetchPage(); } private async Task NextPage() { _pageNumber++; await FetchPage(); }
private void ToggleRow(int idx)
{
if (!_expandedRows.Add(idx))
{
_expandedRows.Remove(idx);
}
}
private async Task CopyAsync(string text)
{
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
_toast.ShowSuccess("Copied to clipboard.");
}
catch
{
_toast.ShowError("Copy failed.");
}
}
private async Task FetchPage() private async Task FetchPage()
{ {
_searching = true; _searching = true;
@@ -141,6 +211,7 @@
{ {
_messages = response.Messages.ToList(); _messages = response.Messages.ToList();
_totalCount = response.TotalCount; _totalCount = response.TotalCount;
_expandedRows.Clear();
} }
else else
{ {
@@ -157,6 +228,8 @@
private async Task RetryMessage(ParkedMessageEntry msg) private async Task RetryMessage(ParkedMessageEntry msg)
{ {
_actionInProgress = true; _actionInProgress = true;
_activeMessageId = msg.MessageId;
_activeAction = "Retry";
try try
{ {
var request = new ParkedMessageRetryRequest( var request = new ParkedMessageRetryRequest(
@@ -179,6 +252,8 @@
{ {
_toast.ShowError($"Retry failed: {ex.Message}"); _toast.ShowError($"Retry failed: {ex.Message}");
} }
_activeMessageId = null;
_activeAction = null;
_actionInProgress = false; _actionInProgress = false;
} }
@@ -190,6 +265,8 @@
if (!confirmed) return; if (!confirmed) return;
_actionInProgress = true; _actionInProgress = true;
_activeMessageId = msg.MessageId;
_activeAction = "Discard";
try try
{ {
var request = new ParkedMessageDiscardRequest( var request = new ParkedMessageDiscardRequest(
@@ -212,6 +289,8 @@
{ {
_toast.ShowError($"Discard failed: {ex.Message}"); _toast.ShowError($"Discard failed: {ex.Message}");
} }
_activeMessageId = null;
_activeAction = null;
_actionInProgress = false; _actionInProgress = false;
} }
} }
@@ -1,9 +1,16 @@
@* Reusable confirmation dialog using Bootstrap modal *@ @* Reusable confirmation dialog using Bootstrap modal.
z-index ladder: Toast container 1090 > this modal 1055 > this backdrop 1040. *@
@inject IJSRuntime JS
@implements IAsyncDisposable
@if (_visible) @if (_visible)
{ {
<div class="modal-backdrop fade show"></div> <div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog"> <div @ref="_modalRef"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
@onkeydown="OnKeyDownAsync">
<div class="modal-dialog modal-dialog-centered" role="document"> <div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -24,12 +31,14 @@
@code { @code {
private bool _visible; private bool _visible;
private bool _bodyLocked;
private TaskCompletionSource<bool>? _tcs; private TaskCompletionSource<bool>? _tcs;
private ElementReference _modalRef;
[Parameter] public string Title { get; set; } = "Confirm"; [Parameter] public string Title { get; set; } = "Confirm";
[Parameter] public string Message { get; set; } = "Are you sure?"; [Parameter] public string Message { get; set; } = "Are you sure?";
[Parameter] public string ConfirmText { get; set; } = "Confirm"; [Parameter] public string ConfirmText { get; set; } = "Confirm";
[Parameter] public string ConfirmButtonClass { get; set; } = "btn-danger"; [Parameter] public string ConfirmButtonClass { get; set; } = "btn-primary";
public Task<bool> ShowAsync(string? message = null, string? title = null) public Task<bool> ShowAsync(string? message = null, string? title = null)
{ {
@@ -41,15 +50,82 @@
return _tcs.Task; return _tcs.Task;
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_visible && !_bodyLocked)
{
_bodyLocked = true;
await TryLockBodyAsync();
// Focus the modal so the @onkeydown handler receives Escape.
try { await _modalRef.FocusAsync(); }
catch { /* prerender or detached: ignore */ }
}
}
private async Task OnKeyDownAsync(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
await CancelAsync();
}
}
private void Confirm() private void Confirm()
{ {
_visible = false; Close(true);
_tcs?.TrySetResult(true);
} }
private void Cancel() private void Cancel()
{
Close(false);
}
private Task CancelAsync()
{
Close(false);
return Task.CompletedTask;
}
private void Close(bool result)
{ {
_visible = false; _visible = false;
_tcs?.TrySetResult(false); _ = TryUnlockBodyAsync();
_tcs?.TrySetResult(result);
}
private async Task TryLockBodyAsync()
{
try
{
await JS.InvokeVoidAsync("document.body.classList.add", "modal-open");
}
catch
{
// Prerendering has no JS runtime; log only.
try { await JS.InvokeVoidAsync("console.debug", "ConfirmDialog: JS interop unavailable for body lock."); }
catch { /* swallow */ }
}
}
private async Task TryUnlockBodyAsync()
{
_bodyLocked = false;
try
{
await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open");
}
catch
{
try { await JS.InvokeVoidAsync("console.debug", "ConfirmDialog: JS interop unavailable for body unlock."); }
catch { /* swallow */ }
}
}
public async ValueTask DisposeAsync()
{
if (_bodyLocked)
{
await TryUnlockBodyAsync();
}
} }
} }
@@ -6,8 +6,15 @@
{ {
<div class="row mb-2"> <div class="row mb-2">
<div class="col-md-4"> <div class="col-md-4">
<div class="input-group input-group-sm">
<input type="text" class="form-control form-control-sm" placeholder="Search..." <input type="text" class="form-control form-control-sm" placeholder="Search..."
@bind="_searchTerm" @bind:event="oninput" @bind:after="ApplyFilter" /> @bind="_searchTerm" @bind:event="oninput" @bind:after="ApplyFilter" />
@if (!string.IsNullOrEmpty(_searchTerm))
{
<button type="button" class="btn btn-outline-secondary"
aria-label="Clear search" @onclick="ClearSearch">&times;</button>
}
</div>
</div> </div>
@if (FilterContent != null) @if (FilterContent != null)
{ {
@@ -47,7 +54,10 @@
<nav> <nav>
<ul class="pagination pagination-sm justify-content-end"> <ul class="pagination pagination-sm justify-content-end">
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")"> <li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage - 1)">Previous</button> <button class="page-link" type="button"
disabled="@(_currentPage <= 1)"
aria-disabled="@((_currentPage <= 1).ToString().ToLowerInvariant())"
@onclick="() => GoToPage(_currentPage - 1)">Previous</button>
</li> </li>
@for (int i = 1; i <= _totalPages; i++) @for (int i = 1; i <= _totalPages; i++)
{ {
@@ -57,7 +67,10 @@
</li> </li>
} }
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")"> <li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage + 1)">Next</button> <button class="page-link" type="button"
disabled="@(_currentPage >= _totalPages)"
aria-disabled="@((_currentPage >= _totalPages).ToString().ToLowerInvariant())"
@onclick="() => GoToPage(_currentPage + 1)">Next</button>
</li> </li>
</ul> </ul>
</nav> </nav>
@@ -112,6 +125,12 @@
UpdatePage(); UpdatePage();
} }
private void ClearSearch()
{
_searchTerm = string.Empty;
ApplyFilter();
}
private void UpdatePage() private void UpdatePage()
{ {
_pagedItems = _filteredItems _pagedItems = _filteredItems
@@ -0,0 +1,158 @@
@* Reusable diff/comparison dialog using Bootstrap modal.
Mirrors the ConfirmDialog API: callers invoke ShowAsync(title, before, after)
via @ref to display a side-by-side or simple before/after comparison.
z-index ladder follows ConfirmDialog: modal 1055 > backdrop 1040 (toasts at 1090). *@
@inject IJSRuntime JS
@implements IAsyncDisposable
@if (_visible)
{
<div class="modal-backdrop fade show"></div>
<div @ref="_modalRef"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
@onkeydown="OnKeyDownAsync">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" aria-label="Close diff dialog" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (BodyContent != null)
{
@BodyContent
}
else
{
<div class="row g-3">
<div class="col-md-6">
<div class="small text-muted mb-1">Before</div>
<pre class="border rounded p-2 small bg-light mb-0" style="max-height: 50vh; overflow: auto; white-space: pre-wrap;">@Before</pre>
</div>
<div class="col-md-6">
<div class="small text-muted mb-1">After</div>
<pre class="border rounded p-2 small bg-light mb-0" style="max-height: 50vh; overflow: auto; white-space: pre-wrap;">@After</pre>
</div>
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" @onclick="Close">Close</button>
</div>
</div>
</div>
</div>
}
@code {
private bool _visible;
private bool _bodyLocked;
private TaskCompletionSource<bool>? _tcs;
private ElementReference _modalRef;
[Parameter] public string Title { get; set; } = "Diff";
[Parameter] public string Before { get; set; } = string.Empty;
[Parameter] public string After { get; set; } = string.Empty;
/// <summary>
/// Optional custom body content. When supplied, it replaces the default
/// before/after panes — useful when the caller wants to render a richer
/// comparison (e.g. metadata badges, file lists, etc.).
/// </summary>
[Parameter] public RenderFragment? BodyContent { get; set; }
/// <summary>
/// Show the dialog with the supplied title and before/after text.
/// Returns when the user dismisses the dialog.
/// </summary>
public Task<bool> ShowAsync(string title, string before, string after)
{
Title = title;
Before = before;
After = after;
BodyContent = null;
return OpenAsync();
}
/// <summary>
/// Show the dialog with a custom body. Useful when the diff is not a
/// simple before/after string pair (e.g. a deployment comparison summary).
/// </summary>
public Task<bool> ShowAsync(string title, RenderFragment body)
{
Title = title;
BodyContent = body;
return OpenAsync();
}
private Task<bool> OpenAsync()
{
_visible = true;
_tcs = new TaskCompletionSource<bool>();
StateHasChanged();
return _tcs.Task;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_visible && !_bodyLocked)
{
_bodyLocked = true;
await TryLockBodyAsync();
try { await _modalRef.FocusAsync(); }
catch { /* prerender or detached: ignore */ }
}
}
private async Task OnKeyDownAsync(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
Close();
await Task.CompletedTask;
}
}
private void Close()
{
_visible = false;
_ = TryUnlockBodyAsync();
_tcs?.TrySetResult(true);
}
private async Task TryLockBodyAsync()
{
try
{
await JS.InvokeVoidAsync("document.body.classList.add", "modal-open");
}
catch
{
try { await JS.InvokeVoidAsync("console.debug", "DiffDialog: JS interop unavailable for body lock."); }
catch { /* swallow */ }
}
}
private async Task TryUnlockBodyAsync()
{
_bodyLocked = false;
try
{
await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open");
}
catch
{
try { await JS.InvokeVoidAsync("console.debug", "DiffDialog: JS interop unavailable for body unlock."); }
catch { /* swallow */ }
}
}
public async ValueTask DisposeAsync()
{
if (_bodyLocked)
{
await TryUnlockBodyAsync();
}
}
}
@@ -2,7 +2,7 @@
@if (IsLoading) @if (IsLoading)
{ {
<div class="d-flex align-items-center text-muted @CssClass"> <div class="d-flex align-items-center text-secondary @CssClass">
<div class="spinner-border spinner-border-sm me-2" role="status"> <div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
@@ -1,7 +1,8 @@
@if (IsVisible) @if (IsVisible)
{ {
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);"> <div class="modal-backdrop fade show"></div>
<div class="modal-dialog modal-sm"> <div class="modal fade show d-block" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-centered modal-sm" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h6 class="modal-title">New Folder</h6> <h6 class="modal-title">New Folder</h6>
@@ -1,7 +1,14 @@
<div class="container mt-5"> <div class="d-flex align-items-center justify-content-center min-vh-100">
<div class="card shadow-sm" style="max-width: 480px; width: 100%;">
<div class="card-body p-4">
<h4 class="card-title mb-3 text-center">ScadaLink</h4>
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<h5 class="alert-heading">Not Authorized</h5> <h5 class="alert-heading">Not Authorized</h5>
<p class="mb-0">You do not have permission to access this page. Contact your administrator if you believe this is an error.</p> <p class="mb-0">You do not have permission to access this page. Contact your administrator if you believe this is an error.</p>
</div> </div>
<div class="d-flex justify-content-center">
<a href="/" class="btn btn-outline-primary btn-sm">Return to Dashboard</a> <a href="/" class="btn btn-outline-primary btn-sm">Return to Dashboard</a>
</div> </div>
</div>
</div>
</div>
@@ -1,7 +1,17 @@
@* Reusable toast notification component *@ @*
Reusable toast notification component.
z-index ladder:
Toast container 1090 (this component, on top)
ConfirmDialog modal element 1055 (Bootstrap default for .modal)
ConfirmDialog backdrop 1040 (Bootstrap default for .modal-backdrop)
Toasts intentionally float above ConfirmDialog so confirmation feedback
(Success/Error) is visible even while a dialog is open.
*@
@implements IDisposable @implements IDisposable
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1090;"> <div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1090;" aria-live="polite" aria-atomic="true">
@foreach (var toast in _toasts) @foreach (var toast in _toasts)
{ {
<div class="toast show mb-2" role="alert"> <div class="toast show mb-2" role="alert">
@@ -15,30 +25,32 @@
</div> </div>
@code { @code {
private const int DefaultAutoDismissMs = 5000;
private readonly List<ToastItem> _toasts = new(); private readonly List<ToastItem> _toasts = new();
private readonly object _lock = new(); private readonly object _lock = new();
public void ShowSuccess(string message, string title = "Success") public void ShowSuccess(string message, string title = "Success", int? autoDismissMs = null)
{ {
AddToast(title, message, ToastType.Success); AddToast(title, message, ToastType.Success, autoDismissMs);
} }
public void ShowError(string message, string title = "Error") public void ShowError(string message, string title = "Error", int? autoDismissMs = null)
{ {
AddToast(title, message, ToastType.Error); AddToast(title, message, ToastType.Error, autoDismissMs);
} }
public void ShowWarning(string message, string title = "Warning") public void ShowWarning(string message, string title = "Warning", int? autoDismissMs = null)
{ {
AddToast(title, message, ToastType.Warning); AddToast(title, message, ToastType.Warning, autoDismissMs);
} }
public void ShowInfo(string message, string title = "Info") public void ShowInfo(string message, string title = "Info", int? autoDismissMs = null)
{ {
AddToast(title, message, ToastType.Info); AddToast(title, message, ToastType.Info, autoDismissMs);
} }
private void AddToast(string title, string message, ToastType type) private void AddToast(string title, string message, ToastType type, int? autoDismissMs)
{ {
var toast = new ToastItem { Title = title, Message = message, Type = type }; var toast = new ToastItem { Title = title, Message = message, Type = type };
lock (_lock) lock (_lock)
@@ -47,8 +59,8 @@
} }
StateHasChanged(); StateHasChanged();
// Auto-dismiss after 5 seconds var dismissMs = autoDismissMs ?? DefaultAutoDismissMs;
_ = Task.Delay(5000).ContinueWith(_ => _ = Task.Delay(dismissMs).ContinueWith(_ =>
{ {
lock (_lock) lock (_lock)
{ {
@@ -133,3 +133,20 @@
[role="treeitem"][aria-expanded="true"] > .tv-row .tv-toggle .bi-chevron-right { [role="treeitem"][aria-expanded="true"] > .tv-row .tv-toggle .bi-chevron-right {
transform: rotate(90deg); transform: rotate(90deg);
} }
/* Per-row kebab (More actions): hidden by default, revealed on row hover or when
the dropdown is open. Consumers render `<span class="tv-kebab ...">` inside
NodeContent to opt in. */
.tv-row .tv-kebab {
flex: 0 0 auto;
opacity: 0;
transition: opacity 0.1s linear;
margin-left: 0.25rem;
}
.tv-row:hover .tv-kebab,
.tv-row:focus-within .tv-kebab,
.tv-row .tv-kebab.show,
.tv-row .tv-kebab .show {
opacity: 1;
}
@@ -0,0 +1,75 @@
/* ScadaLink Central UI global styles. Loaded from Host App.razor as
`_content/ScadaLink.CentralUI/css/site.css`. */
.sidebar {
min-width: 220px;
max-width: 220px;
min-height: 100vh;
background-color: #212529;
}
.sidebar .nav-link {
color: #adb5bd;
padding: 0.4rem 1rem;
font-size: 0.9rem;
}
.sidebar .nav-link:hover {
color: #fff;
background-color: #343a40;
}
.sidebar .nav-link.active {
color: #fff;
background-color: #0d6efd;
/* Left accent so active state isn't carried by color alone. */
border-left: 3px solid #0d6efd;
padding-left: calc(1rem - 3px);
}
.sidebar .nav-section-header {
color: #6c757d;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.75rem 1rem 0.25rem;
margin-top: 0.5rem;
}
.sidebar .brand {
color: #fff;
font-size: 1.1rem;
font-weight: 600;
padding: 1rem;
border-bottom: 1px solid #343a40;
}
/* When the sidebar is collapsed under <lg viewports the Bootstrap collapse
container removes the fixed width; restore full width on mobile. */
@media (max-width: 991.98px) {
.sidebar {
min-width: 100%;
max-width: 100%;
min-height: auto;
}
}
#reconnect-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background-color: rgba(0, 0, 0, 0.5);
}
#reconnect-modal .modal-content {
max-width: 400px;
padding: 2rem;
text-align: center;
background: #fff;
border-radius: 0.5rem;
}
+2 -58
View File
@@ -8,70 +8,14 @@
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" /> <link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
<link href="/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet" /> <link href="/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet" />
<link href="/ScadaLink.Host.styles.css" rel="stylesheet" /> <link href="/ScadaLink.Host.styles.css" rel="stylesheet" />
<style> <link href="_content/ScadaLink.CentralUI/css/site.css" rel="stylesheet" />
.sidebar {
min-width: 220px;
max-width: 220px;
min-height: 100vh;
background-color: #212529;
}
.sidebar .nav-link {
color: #adb5bd;
padding: 0.4rem 1rem;
font-size: 0.9rem;
}
.sidebar .nav-link:hover {
color: #fff;
background-color: #343a40;
}
.sidebar .nav-link.active {
color: #fff;
background-color: #0d6efd;
}
.sidebar .nav-section-header {
color: #6c757d;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.75rem 1rem 0.25rem;
margin-top: 0.5rem;
}
.sidebar .brand {
color: #fff;
font-size: 1.1rem;
font-weight: 600;
padding: 1rem;
border-bottom: 1px solid #343a40;
}
#reconnect-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 9999;
background-color: rgba(0,0,0,0.5);
}
#reconnect-modal .modal-dialog {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
#reconnect-modal .modal-content {
max-width: 400px;
padding: 2rem;
text-align: center;
background: #fff;
border-radius: 0.5rem;
}
</style>
<HeadOutlet @rendermode="InteractiveServer" /> <HeadOutlet @rendermode="InteractiveServer" />
</head> </head>
<body> <body>
<Routes @rendermode="InteractiveServer" /> <Routes @rendermode="InteractiveServer" />
<div id="reconnect-modal"> <div id="reconnect-modal">
<div class="modal-dialog"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="spinner-border text-primary mb-3" role="status"> <div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Reconnecting...</span> <span class="visually-hidden">Reconnecting...</span>
@@ -60,10 +60,12 @@ public class DataConnectionFormTests : BunitContext
.First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true) .First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true)
.Change("not-a-url"); .Change("not-a-url");
// Name field (the first text input that is NOT the OPC URL) // Name field (the first editable text input that is NOT the OPC URL).
// Site renders as a readonly plaintext input when locked — skip it.
cut.FindAll("input[type='text']") cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder") is null .First(i => !i.HasAttribute("readonly")
|| !i.GetAttribute("placeholder")!.StartsWith("opc.tcp")) && (i.GetAttribute("placeholder") is null
|| !i.GetAttribute("placeholder")!.StartsWith("opc.tcp")))
.Change("My Connection"); .Change("My Connection");
await cut.FindAll("button") await cut.FindAll("button")
@@ -82,10 +84,11 @@ public class DataConnectionFormTests : BunitContext
var cut = RenderForCreateSite(1); var cut = RenderForCreateSite(1);
// Name // Name (skip readonly Site plaintext input)
cut.FindAll("input[type='text']") cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder") is null .First(i => !i.HasAttribute("readonly")
|| !i.GetAttribute("placeholder")!.StartsWith("opc.tcp")) && (i.GetAttribute("placeholder") is null
|| !i.GetAttribute("placeholder")!.StartsWith("opc.tcp")))
.Change("PLC-1"); .Change("PLC-1");
// Endpoint URL // Endpoint URL
cut.FindAll("input[type='text']") cut.FindAll("input[type='text']")