Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e21791adb0 | |||
| 321ca0bbbf | |||
| b6e2ec8a50 | |||
| da2c0d714e | |||
| f7b10f2ff7 | |||
| ff5f5a10ef |
@@ -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 `←` 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;">
|
||||||
<NavMenu />
|
@* Hamburger toggle: visible only on viewports <lg.
|
||||||
<main class="flex-grow-1 p-3" style="min-height: 100vh; background-color: #f8f9fa;">
|
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">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse d-lg-block" id="sidebar-collapse">
|
||||||
|
<NavMenu />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="flex-grow-1 p-3" style="background-color: #f8f9fa;">
|
||||||
@Body
|
@Body
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,87 +3,92 @@
|
|||||||
<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;">
|
||||||
<li class="nav-item">
|
<ul class="nav flex-column">
|
||||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
<li class="nav-item">
|
||||||
</li>
|
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
<AuthorizeView>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
@* 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>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
|
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/admin/connections">Connections</NavLink>
|
<NavLink class="nav-link" href="/admin/connections">Connections</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||||
</li>
|
</li>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
@* 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>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
|
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<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>
|
||||||
</Authorized>
|
<li class="nav-item">
|
||||||
</AuthorizeView>
|
<NavLink class="nav-link" href="/design/smtp">SMTP Configuration</NavLink>
|
||||||
|
</li>
|
||||||
|
</Authorized>
|
||||||
|
</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>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
|
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
|
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
|
||||||
</li>
|
</li>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</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>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
|
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
|
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@* Audit Log — Admin only *@
|
@* Audit Log — Admin only *@
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||||
<Authorized Context="auditContext">
|
<Authorized Context="auditContext">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/monitoring/audit-log">Audit Log</NavLink>
|
<NavLink class="nav-link" href="/monitoring/audit-log">Audit Log</NavLink>
|
||||||
</li>
|
</li>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
</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">
|
||||||
@if (_saved)
|
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2"
|
||||||
{
|
aria-label="Back to API Keys">← Back</a>
|
||||||
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">← Back to API Keys</a>
|
<span class="text-muted me-2">·</span>
|
||||||
}
|
<h4 class="mb-0">
|
||||||
else
|
@if (_saved)
|
||||||
{
|
{
|
||||||
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">← Back</a>
|
@:API Key Created
|
||||||
}
|
}
|
||||||
<h4 class="mb-0">@(_saved ? "API Key Created" : (_editingKey != null ? "Edit API Key" : "Add API Key"))</h4>
|
else if (IsEditMode)
|
||||||
|
{
|
||||||
|
@:Edit API Key
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@:Add API Key
|
||||||
|
}
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ToastNotification @ref="_toast" />
|
||||||
|
|
||||||
@if (_loading)
|
@if (_loading)
|
||||||
{
|
{
|
||||||
<LoadingSpinner IsLoading="true" />
|
<LoadingSpinner IsLoading="true" />
|
||||||
@@ -42,21 +53,17 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="card">
|
<div class="mb-2">
|
||||||
<div class="card-body">
|
<label class="form-label small">Name</label>
|
||||||
<div class="mb-2">
|
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||||
<label class="form-label small">Name</label>
|
</div>
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
@if (_formError != null)
|
||||||
</div>
|
{
|
||||||
@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">
|
||||||
}
|
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
|
||||||
<div class="mt-3">
|
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +71,8 @@
|
|||||||
@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)
|
||||||
{
|
{
|
||||||
@@ -25,59 +25,75 @@
|
|||||||
}
|
}
|
||||||
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…"
|
||||||
<th>ID</th>
|
@bind="_search" @bind:event="oninput" />
|
||||||
<th>Name</th>
|
</div>
|
||||||
<th>Key Value</th>
|
|
||||||
<th>Status</th>
|
@if (_keys.Count == 0)
|
||||||
<th style="width: 240px;">Actions</th>
|
{
|
||||||
</tr>
|
<p class="text-muted text-center">No API keys configured.</p>
|
||||||
</thead>
|
}
|
||||||
<tbody>
|
else if (!FilteredKeys.Any())
|
||||||
@if (_keys.Count == 0)
|
{
|
||||||
{
|
<p class="text-muted small">No API keys match the filter.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-striped table-hover">
|
||||||
|
<thead class="table-dark">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-muted text-center">No API keys configured.</td>
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Key Value</th>
|
||||||
|
<th style="width: 160px;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
</thead>
|
||||||
@foreach (var key in _keys)
|
<tbody>
|
||||||
{
|
@foreach (var key in FilteredKeys)
|
||||||
<tr>
|
{
|
||||||
<td>@key.Id</td>
|
<tr @key="key.Id">
|
||||||
<td>@key.Name</td>
|
<td>@key.Id</td>
|
||||||
<td><code>@MaskKeyValue(key.KeyValue)</code></td>
|
<td>
|
||||||
<td>
|
@key.Name
|
||||||
@if (key.IsEnabled)
|
@if (!key.IsEnabled)
|
||||||
{
|
{
|
||||||
<span class="badge bg-success">Enabled</span>
|
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||||
}
|
}
|
||||||
else
|
</td>
|
||||||
{
|
<td><code>@MaskKeyValue(key.KeyValue)</code></td>
|
||||||
<span class="badge bg-secondary">Disabled</span>
|
<td>
|
||||||
}
|
<div class="d-flex gap-1">
|
||||||
</td>
|
<button class="btn btn-outline-primary btn-sm py-0 px-2"
|
||||||
<td>
|
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
|
||||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
<div class="dropdown">
|
||||||
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
|
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
|
||||||
@if (key.IsEnabled)
|
data-bs-toggle="dropdown"
|
||||||
{
|
aria-label="@($"More actions for {key.Name}")">⋮</button>
|
||||||
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
@onclick="() => ToggleKey(key)">Disable</button>
|
<li>
|
||||||
}
|
<button class="dropdown-item"
|
||||||
else
|
@onclick="() => ToggleKey(key)">
|
||||||
{
|
@(key.IsEnabled ? "Disable" : "Enable")
|
||||||
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
|
</button>
|
||||||
@onclick="() => ToggleKey(key)">Enable</button>
|
</li>
|
||||||
}
|
<li><hr class="dropdown-divider" /></li>
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
<li>
|
||||||
@onclick="() => DeleteKey(key)">Delete</button>
|
<button class="dropdown-item text-danger"
|
||||||
</td>
|
@onclick="() => DeleteKey(key)">
|
||||||
</tr>
|
Delete
|
||||||
}
|
</button>
|
||||||
</tbody>
|
</li>
|
||||||
</table>
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,10 +101,17 @@
|
|||||||
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">
|
||||||
← Back
|
← 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,56 +48,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (IsEditMode && _formRole.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
|
<div class="card mb-3">
|
||||||
{
|
<div class="card-body">
|
||||||
<div class="card mb-3">
|
<h5 class="card-title">Site Scope Rules</h5>
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-title">Site Scope Rules</h6>
|
|
||||||
|
|
||||||
|
@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">
|
@foreach (var rule in _scopeRules)
|
||||||
<tr>
|
{
|
||||||
<th>ID</th>
|
var siteName = _siteLookup.GetValueOrDefault(rule.SiteId)?.Name ?? $"Site {rule.SiteId}";
|
||||||
<th>Site ID</th>
|
<span class="badge bg-info text-dark d-inline-flex align-items-center">
|
||||||
<th style="width: 120px;">Actions</th>
|
@siteName
|
||||||
</tr>
|
<button type="button"
|
||||||
</thead>
|
class="btn-close btn-close-white ms-2"
|
||||||
<tbody>
|
style="font-size: 0.6rem;"
|
||||||
@foreach (var rule in _scopeRules)
|
aria-label="@($"Remove scope rule for {siteName}")"
|
||||||
{
|
@onclick="() => DeleteScopeRule(rule)"></button>
|
||||||
<tr>
|
</span>
|
||||||
<td>@rule.Id</td>
|
}
|
||||||
<td>@rule.SiteId</td>
|
</div>
|
||||||
<td>
|
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
|
||||||
@onclick="() => DeleteScopeRule(rule)">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</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 {
|
||||||
@@ -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;">
|
||||||
<table class="table table-sm table-striped table-hover">
|
<input class="form-control form-control-sm"
|
||||||
<thead class="table-dark">
|
placeholder="Filter by name, LDAP group, or role…"
|
||||||
<tr>
|
@bind="_search" @bind:event="oninput" />
|
||||||
<th>ID</th>
|
</div>
|
||||||
<th>LDAP Group Name</th>
|
|
||||||
<th>Role</th>
|
|
||||||
<th>Site Scope Rules</th>
|
|
||||||
<th style="width: 200px;">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@if (_mappings.Count == 0)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="text-muted text-center">No mappings configured.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
@foreach (var mapping in _mappings)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@mapping.Id</td>
|
|
||||||
<td>@mapping.LdapGroupName</td>
|
|
||||||
<td><span class="badge bg-secondary">@mapping.Role</span></td>
|
|
||||||
<td>
|
|
||||||
@{
|
|
||||||
var rules = _scopeRules.GetValueOrDefault(mapping.Id);
|
|
||||||
}
|
|
||||||
@if (rules != null && rules.Count > 0)
|
|
||||||
{
|
|
||||||
@foreach (var rule in rules)
|
|
||||||
{
|
|
||||||
<span class="badge bg-info text-dark me-1">Site @rule.SiteId</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<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>
|
|
||||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
|
||||||
@onclick='() => NavigationManager.NavigateTo($"/admin/ldap-mappings/{mapping.Id}/edit")'>Edit</button>
|
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
|
||||||
@onclick="() => DeleteMapping(mapping.Id)">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
|
@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">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>LDAP Group Name</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Site Scope</th>
|
||||||
|
<th style="width: 160px;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var mapping in FilteredMappings)
|
||||||
|
{
|
||||||
|
var rules = _scopeRules.GetValueOrDefault(mapping.Id);
|
||||||
|
var ruleCount = rules?.Count ?? 0;
|
||||||
|
<tr @key="mapping.Id">
|
||||||
|
<td>@mapping.Id</td>
|
||||||
|
<td>@mapping.LdapGroupName</td>
|
||||||
|
<td><span class="badge bg-secondary">@mapping.Role</span></td>
|
||||||
|
<td>
|
||||||
|
@if (ruleCount > 0)
|
||||||
|
{
|
||||||
|
<span class="badge bg-info text-dark">@ruleCount rule(s)</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted small">All sites</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<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>
|
||||||
|
<div class="dropdown">
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</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">
|
||||||
|
<h4 class="mb-0">Welcome to ScadaLink</h4>
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<span class="text-muted small">
|
||||||
|
Signed in as <strong>@context.User.FindFirst("DisplayName")?.Value</strong>
|
||||||
|
</span>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</div>
|
||||||
<p class="text-muted">Central management console for the ScadaLink SCADA system.</p>
|
<p class="text-muted">Central management console for the ScadaLink SCADA system.</p>
|
||||||
|
|
||||||
<AuthorizeView>
|
@* KPI row *@
|
||||||
<Authorized>
|
<div class="row g-3 mb-4">
|
||||||
<div class="card mt-3" style="max-width: 500px;">
|
<div class="col-lg-3 col-md-6 col-12">
|
||||||
<div class="card-body">
|
<div class="card h-100">
|
||||||
<h6 class="card-subtitle mb-2 text-muted">Signed in as</h6>
|
<div class="card-body text-center">
|
||||||
<p class="card-text mb-1"><strong>@context.User.FindFirst("DisplayName")?.Value</strong></p>
|
<div class="fs-2 fw-bold">@(_loaded ? _siteCount.ToString() : "—")</div>
|
||||||
<p class="card-text small text-muted mb-2">@context.User.FindFirst("Username")?.Value</p>
|
<div class="text-muted small">Sites configured</div>
|
||||||
|
|
||||||
@{
|
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</div>
|
||||||
</AuthorizeView>
|
<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">→</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">→</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">→</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Design templates, shared scripts, and external systems.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
<strong>Attribute Values</strong>
|
<div class="d-flex align-items-center gap-2">
|
||||||
<small class="text-muted">@_attributeValues.Count values</small>
|
<strong>Attribute Values</strong>
|
||||||
|
<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">
|
||||||
<strong>Alarm States</strong>
|
<div class="d-flex align-items-center gap-2">
|
||||||
<small class="text-muted">@_alarmStates.Count alarms</small>
|
<strong>Alarm States</strong>
|
||||||
|
<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,9 +389,16 @@
|
|||||||
<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>
|
<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>
|
||||||
|
}
|
||||||
|
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>
|
<div class="text-center py-5 text-muted">
|
||||||
@foreach (var es in _externalSystems)
|
<p class="mb-3">No external systems configured.</p>
|
||||||
|
<button class="btn btn-primary btn-sm"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo("/design/external-systems/create")'>
|
||||||
|
Add your first external system
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
<tr>
|
<div class="col-lg-6 col-12" @key="es.Id">
|
||||||
<td>@es.Name</td><td class="small">@es.EndpointUrl</td><td><span class="badge bg-secondary">@es.AuthType</span></td>
|
<div class="card h-100">
|
||||||
<td class="small">@es.MaxRetries</td><td class="small">@es.RetryDelay.TotalSeconds s</td>
|
<div class="card-body">
|
||||||
<td>
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<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>
|
<h5 class="card-title mb-0">@es.Name</h5>
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteExtSys(es)">Delete</button>
|
<div class="d-flex gap-1">
|
||||||
</td>
|
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/external-systems/{es.Id}/edit")'>Edit</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 {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>
|
||||||
}
|
}
|
||||||
</tbody>
|
</div>
|
||||||
</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>
|
<div class="text-center py-5 text-muted">
|
||||||
@foreach (var dc in _dbConnections)
|
<p class="mb-3">No database connections configured.</p>
|
||||||
|
<button class="btn btn-primary btn-sm"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo("/design/db-connections/create")'>
|
||||||
|
Add your first database connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
<tr>
|
<div class="col-lg-6 col-12" @key="dc.Id">
|
||||||
<td>@dc.Name</td><td class="small text-muted text-truncate" style="max-width:400px;">@dc.ConnectionString</td>
|
<div class="card h-100">
|
||||||
<td class="small">@dc.MaxRetries</td><td class="small">@dc.RetryDelay.TotalSeconds s</td>
|
<div class="card-body">
|
||||||
<td>
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<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>
|
<h5 class="card-title mb-0">@dc.Name</h5>
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteDbConn(dc)">Delete</button>
|
<div class="d-flex gap-1">
|
||||||
</td>
|
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/db-connections/{dc.Id}/edit")'>Edit</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 {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>
|
||||||
}
|
}
|
||||||
</tbody>
|
</div>
|
||||||
</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>
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-dark me-1 mb-1">@r.Name <@r.EmailAddress></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</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
|
|
||||||
{
|
|
||||||
@foreach (var r in recips)
|
|
||||||
{
|
|
||||||
<span class="badge bg-light text-dark me-1 mb-1">
|
|
||||||
@r.Name <@r.EmailAddress>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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>
|
<div class="text-center py-5 text-muted">
|
||||||
@foreach (var m in _apiMethods)
|
<p class="mb-3">No API methods configured.</p>
|
||||||
|
<button class="btn btn-primary btn-sm"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo("/design/api-methods/create")'>
|
||||||
|
Add your first API method
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
<tr>
|
var preview = m.Script.Length > 80 ? m.Script[..80] + "…" : m.Script;
|
||||||
<td><code>POST /api/@m.Name</code></td>
|
<div class="col-lg-6 col-12" @key="m.Id">
|
||||||
<td>@m.TimeoutSeconds s</td>
|
<div class="card h-100">
|
||||||
<td class="small font-monospace text-truncate" style="max-width:300px;">@m.Script[..Math.Min(60, m.Script.Length)]</td>
|
<div class="card-body">
|
||||||
<td>
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<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>
|
<div>
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteApiMethod(m)">Delete</button>
|
<h5 class="card-title mb-1">@m.Name</h5>
|
||||||
</td>
|
<code class="small">POST /api/@m.Name</code>
|
||||||
</tr>
|
</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>
|
||||||
}
|
}
|
||||||
</tbody>
|
</div>
|
||||||
</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>
|
|
||||||
@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>
|
</div>
|
||||||
}
|
}
|
||||||
};
|
else
|
||||||
|
|
||||||
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)
|
<div class="mb-3" style="max-width: 320px;">
|
||||||
{
|
<input class="form-control form-control-sm"
|
||||||
_editingSmtp.Host = _smtpHost.Trim();
|
placeholder="Filter by name…"
|
||||||
_editingSmtp.Port = _smtpPort;
|
@bind="_apiKeySearch" @bind:event="oninput" />
|
||||||
_editingSmtp.AuthType = _smtpAuthType;
|
</div>
|
||||||
_editingSmtp.FromAddress = _smtpFromAddress.Trim();
|
|
||||||
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var smtp = new SmtpConfiguration(_smtpHost.Trim(), _smtpAuthType, _smtpFromAddress.Trim()) { Port = _smtpPort };
|
|
||||||
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
|
|
||||||
}
|
|
||||||
await NotificationRepository.SaveChangesAsync();
|
|
||||||
_showSmtpForm = false;
|
|
||||||
_toast.ShowSuccess("SMTP configuration saved.");
|
|
||||||
await LoadAllAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex) { _smtpFormError = ex.Message; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==== API Key → Method Assignments ====
|
@if (!FilteredApiKeys.Any())
|
||||||
private RenderFragment RenderApiKeyMethodAssignments() => __builder =>
|
{
|
||||||
{
|
<p class="text-muted small">No API keys match the filter.</p>
|
||||||
<hr class="my-3" />
|
}
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<h6 class="mb-0">API Keys</h6>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="table table-sm table-striped">
|
<div class="row g-3">
|
||||||
<thead class="table-dark"><tr><th>Key Name</th><th>Enabled</th><th style="width:120px;">Actions</th></tr></thead>
|
@foreach (var key in FilteredApiKeys)
|
||||||
<tbody>
|
|
||||||
@foreach (var key in _apiKeys)
|
|
||||||
{
|
{
|
||||||
<tr>
|
<div class="col-lg-6 col-12" @key="key.Id">
|
||||||
<td>@key.Name</td>
|
<div class="card h-100">
|
||||||
<td><span class="badge @(key.IsEnabled ? "bg-success" : "bg-secondary")">@(key.IsEnabled ? "Enabled" : "Disabled")</span></td>
|
<div class="card-body">
|
||||||
<td>
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<button class="btn btn-outline-primary btn-sm py-0 px-1" @onclick="() => ToggleApiKeyEnabled(key)">
|
<h5 class="card-title mb-0">@key.Name</h5>
|
||||||
@(key.IsEnabled ? "Disable" : "Enable")
|
<span class="badge @(key.IsEnabled ? "bg-success" : "bg-secondary")">
|
||||||
</button>
|
@(key.IsEnabled ? "Enabled" : "Disabled")
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
</div>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-outline-primary btn-sm"
|
||||||
|
@onclick="() => ToggleApiKeyEnabled(key)">
|
||||||
|
@(key.IsEnabled ? "Disable" : "Enable")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</tbody>
|
</div>
|
||||||
</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>
|
<p class="text-muted small">No shared scripts match the filter.</p>
|
||||||
</tr>
|
}
|
||||||
</thead>
|
|
||||||
<tbody>
|
<div class="row g-3">
|
||||||
@if (_scripts.Count == 0)
|
@foreach (var s in FilteredScripts)
|
||||||
{
|
{
|
||||||
<tr>
|
var preview = s.Code.Length > 80
|
||||||
<td colspan="6" class="text-muted text-center">No shared scripts configured.</td>
|
? s.Code[..80] + "…"
|
||||||
</tr>
|
: s.Code;
|
||||||
}
|
var paramCount = CountJsonArrayEntries(s.ParameterDefinitions);
|
||||||
@foreach (var script in _scripts)
|
<div class="col-lg-6 col-12" @key="s.Id">
|
||||||
{
|
<div class="card h-100">
|
||||||
<tr>
|
<div class="card-body">
|
||||||
<td>@script.Id</td>
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<td><strong>@script.Name</strong></td>
|
<h5 class="card-title mb-0">@s.Name</h5>
|
||||||
<td class="small text-muted font-monospace text-truncate" style="max-width: 300px;">
|
<div class="d-flex gap-1">
|
||||||
@script.Code[..Math.Min(60, script.Code.Length)]@(script.Code.Length > 60 ? "..." : "")
|
<button class="btn btn-outline-primary btn-sm"
|
||||||
</td>
|
@onclick='() => NavigationManager.NavigateTo($"/design/shared-scripts/{s.Id}/edit")'>
|
||||||
<td class="small text-muted">@(script.ParameterDefinitions ?? "—")</td>
|
Edit
|
||||||
<td class="small text-muted">@(script.ReturnDefinition ?? "—")</td>
|
</button>
|
||||||
<td>
|
<div class="dropdown">
|
||||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
<button class="btn btn-outline-secondary btn-sm"
|
||||||
@onclick='() => NavigationManager.NavigateTo($"/design/shared-scripts/{script.Id}/edit")'>Edit</button>
|
data-bs-toggle="dropdown"
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
aria-expanded="false"
|
||||||
@onclick="() => DeleteScript(script)">Delete</button>
|
aria-label="@($"More actions for {s.Name}")">⋮</button>
|
||||||
</td>
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
</tr>
|
<li>
|
||||||
}
|
<button class="dropdown-item text-danger"
|
||||||
</tbody>
|
@onclick="() => DeleteScript(s)">Delete</button>
|
||||||
</table>
|
</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>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<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">← 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">← 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-md-2">
|
<div class="col-12 text-end">
|
||||||
<button class="btn btn-primary btn-sm" @onclick="UpdateTemplateProperties">Save Properties</button>
|
<button class="btn btn-primary" @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">
|
||||||
@onclick="() => DeleteAttribute(attr)">Delete</button>
|
<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>
|
||||||
|
</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">
|
||||||
@onclick="() => DeleteAlarm(alarm)">Delete</button>
|
<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>
|
||||||
|
</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>
|
||||||
|
@if (_scriptFormError != null)
|
||||||
|
{
|
||||||
|
<div class="col-12"><div class="text-danger small">@_scriptFormError</div></div>
|
||||||
|
}
|
||||||
|
<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>
|
||||||
<div class="mt-2">
|
|
||||||
<label class="form-label small">Code</label>
|
|
||||||
<textarea class="form-control form-control-sm font-monospace" rows="6" @bind="_scriptCode"
|
|
||||||
style="font-size: 0.8rem;"></textarea>
|
|
||||||
</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>
|
|
||||||
@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">
|
||||||
@onclick="() => DeleteScript(script)">Delete</button>
|
<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>
|
||||||
|
</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">
|
||||||
@onclick="() => DeleteComposition(comp)">Delete</button>
|
<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>
|
||||||
|
</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">
|
||||||
@onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
|
<div class="dropdown">
|
||||||
<button class="btn btn-outline-secondary" title="New template at root"
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
|
||||||
@onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>+ Template</button>
|
data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<button class="btn btn-outline-secondary" @onclick="() => _tree.ExpandAll()">Expand</button>
|
Bulk actions
|
||||||
<button class="btn btn-outline-secondary" @onclick="() => _tree.CollapseAll()">Collapse</button>
|
</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>
|
||||||
|
<button class="btn btn-primary btn-sm"
|
||||||
|
title="New template at root"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>+ Template</button>
|
||||||
|
</div>
|
||||||
</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,20 +92,50 @@
|
|||||||
}
|
}
|
||||||
@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)
|
||||||
{
|
{
|
||||||
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
if (isLarge)
|
||||||
@onclick="() => ToggleStateView(entry.Id)">
|
{
|
||||||
@(_expandedEntryId == entry.Id ? "Hide" : "View")
|
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
||||||
</button>
|
@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")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -86,11 +143,11 @@
|
|||||||
}
|
}
|
||||||
</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,55 +8,92 @@
|
|||||||
@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="col-md-2">
|
<div class="row mb-3 g-2 align-items-end">
|
||||||
<label class="form-label small">Site</label>
|
<div class="col-md-2">
|
||||||
<select class="form-select form-select-sm" @bind="_selectedSiteId">
|
<label class="form-label small" for="filter-site">Site</label>
|
||||||
<option value="">Select site...</option>
|
<select id="filter-site" class="form-select form-select-sm" aria-label="Site" @bind="_selectedSiteId">
|
||||||
@foreach (var site in _sites)
|
<option value="">Select site...</option>
|
||||||
{
|
@foreach (var site in _sites)
|
||||||
<option value="@site.SiteIdentifier">@site.Name</option>
|
{
|
||||||
}
|
<option value="@site.SiteIdentifier">@site.Name</option>
|
||||||
</select>
|
}
|
||||||
</div>
|
</select>
|
||||||
<div class="col-md-2">
|
</div>
|
||||||
<label class="form-label small">Event Type</label>
|
<div class="col-md-2">
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_filterEventType" placeholder="e.g. ScriptError" />
|
<label class="form-label small" for="filter-event-type">Event Type</label>
|
||||||
</div>
|
<input id="filter-event-type"
|
||||||
<div class="col-md-1">
|
type="text"
|
||||||
<label class="form-label small">Severity</label>
|
class="form-control form-control-sm"
|
||||||
<select class="form-select form-select-sm" @bind="_filterSeverity">
|
aria-label="Event type"
|
||||||
<option value="">All</option>
|
@bind="_filterEventType"
|
||||||
<option>Info</option>
|
placeholder="e.g. ScriptError" />
|
||||||
<option>Warning</option>
|
</div>
|
||||||
<option>Error</option>
|
<div class="col-md-1">
|
||||||
</select>
|
<label class="form-label small" for="filter-severity">Severity</label>
|
||||||
</div>
|
<select id="filter-severity"
|
||||||
<div class="col-md-2">
|
class="form-select form-select-sm"
|
||||||
<label class="form-label small">From</label>
|
aria-label="Severity"
|
||||||
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterFrom" />
|
@bind="_filterSeverity">
|
||||||
</div>
|
<option value="">All</option>
|
||||||
<div class="col-md-2">
|
<option>Info</option>
|
||||||
<label class="form-label small">To</label>
|
<option>Warning</option>
|
||||||
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterTo" />
|
<option>Error</option>
|
||||||
</div>
|
</select>
|
||||||
<div class="col-md-1">
|
</div>
|
||||||
<label class="form-label small">Keyword</label>
|
<div class="col-md-2">
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_filterKeyword" />
|
<label class="form-label small" for="filter-from">From</label>
|
||||||
</div>
|
<input id="filter-from"
|
||||||
<div class="col-md-2">
|
type="datetime-local"
|
||||||
<label class="form-label small">Instance</label>
|
class="form-control form-control-sm"
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_filterInstanceName" placeholder="Instance name" />
|
aria-label="From timestamp"
|
||||||
</div>
|
@bind="_filterFrom" />
|
||||||
<div class="col-md-1 d-flex align-items-end">
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
|
<div class="col-md-2">
|
||||||
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> }
|
<label class="form-label small" for="filter-to">To</label>
|
||||||
Search
|
<input id="filter-to"
|
||||||
</button>
|
type="datetime-local"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
aria-label="To timestamp"
|
||||||
|
@bind="_filterTo" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small" for="filter-keyword">Message contains</label>
|
||||||
|
<input id="filter-keyword"
|
||||||
|
type="text"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
aria-label="Message contains"
|
||||||
|
@bind="_filterKeyword" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small" for="filter-instance">Instance</label>
|
||||||
|
<input id="filter-instance"
|
||||||
|
type="text"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
aria-label="Instance name"
|
||||||
|
@bind="_filterInstanceName"
|
||||||
|
placeholder="Instance name" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 d-flex gap-2">
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
|
||||||
|
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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>
|
||||||
@if (_hasMore)
|
<div>
|
||||||
{
|
@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,128 +108,164 @@
|
|||||||
{
|
{
|
||||||
<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"
|
||||||
@if (report.DataConnectionStatuses.Count == 0)
|
data-bs-toggle="collapse"
|
||||||
{
|
data-bs-target="@($"#{detailsCollapseId}-conns")"
|
||||||
<span class="text-muted small">None</span>
|
aria-expanded="false">
|
||||||
}
|
Data Connections (@report.DataConnectionStatuses.Count)
|
||||||
else
|
</button>
|
||||||
{
|
<div class="collapse" id="@($"{detailsCollapseId}-conns")">
|
||||||
@foreach (var (connName, health) in report.DataConnectionStatuses)
|
@if (report.DataConnectionStatuses.Count == 0)
|
||||||
{
|
{
|
||||||
var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName);
|
<span class="text-muted small">None</span>
|
||||||
var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
|
}
|
||||||
<div class="mb-2">
|
else
|
||||||
<div class="d-flex justify-content-between">
|
{
|
||||||
<strong class="small">@connName</strong>
|
@foreach (var (connName, health) in report.DataConnectionStatuses)
|
||||||
<span class="small">@(endpoint ?? health.ToString())</span>
|
{
|
||||||
|
var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName);
|
||||||
|
var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<strong class="small">@connName</strong>
|
||||||
|
<span class="small">@(endpoint ?? health.ToString())</span>
|
||||||
|
</div>
|
||||||
|
@if (quality != null)
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="small text-muted py-0">Tags good</td>
|
||||||
|
<td class="small text-end py-0">@quality.Good.ToString("N0")</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="small text-muted py-0">Tags bad</td>
|
||||||
|
<td class="small text-end py-0">@quality.Bad.ToString("N0")</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="small text-muted py-0">Tags uncertain</td>
|
||||||
|
<td class="small text-end py-0">@quality.Uncertain.ToString("N0")</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (quality != null)
|
}
|
||||||
{
|
|
||||||
<table class="table table-sm table-borderless mb-0">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="small text-muted py-0">Tags good</td>
|
|
||||||
<td class="small text-end py-0">@quality.Good.ToString("N0")</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="small text-muted py-0">Tags bad</td>
|
|
||||||
<td class="small text-end py-0">@quality.Bad.ToString("N0")</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="small text-muted py-0">Tags uncertain</td>
|
|
||||||
<td class="small text-end py-0">@quality.Uncertain.ToString("N0")</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
</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">
|
||||||
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
|
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
|
||||||
<table class="table table-sm table-borderless mb-0">
|
data-bs-toggle="collapse"
|
||||||
<tbody>
|
data-bs-target="@($"#{detailsCollapseId}-queues")"
|
||||||
<tr>
|
aria-expanded="false">
|
||||||
<td class="small">Deployed</td>
|
Instances & Queues
|
||||||
<td class="text-end">@report.DeployedInstanceCount</td>
|
</button>
|
||||||
</tr>
|
<div class="collapse" id="@($"{detailsCollapseId}-queues")">
|
||||||
<tr>
|
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
|
||||||
<td class="small">Enabled</td>
|
<table class="table table-sm table-borderless mb-0">
|
||||||
<td class="text-end text-success">@report.EnabledInstanceCount</td>
|
<tbody>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td class="small">Deployed</td>
|
||||||
<td class="small">Disabled</td>
|
<td class="text-end">@report.DeployedInstanceCount</td>
|
||||||
<td class="text-end">@report.DisabledInstanceCount</td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
</tbody>
|
<td class="small">Enabled</td>
|
||||||
</table>
|
<td class="text-end text-success">@report.EnabledInstanceCount</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="small">Disabled</td>
|
||||||
|
<td class="text-end">@report.DisabledInstanceCount</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Store-and-Forward Buffers</h6>
|
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Store-and-Forward Buffers</h6>
|
||||||
@if (report.StoreAndForwardBufferDepths.Count == 0)
|
@if (report.StoreAndForwardBufferDepths.Count == 0)
|
||||||
{
|
|
||||||
<span class="text-muted small">Empty</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
|
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between mb-1">
|
<span class="text-muted small">Empty</span>
|
||||||
<span class="small">@category</span>
|
|
||||||
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
else
|
||||||
|
{
|
||||||
|
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span class="small">@category</span>
|
||||||
|
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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">
|
||||||
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
|
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
|
||||||
<table class="table table-sm table-borderless mb-0">
|
data-bs-toggle="collapse"
|
||||||
<tbody>
|
data-bs-target="@($"#{detailsCollapseId}-errors")"
|
||||||
<tr>
|
aria-expanded="false">
|
||||||
<td class="small">Script Errors</td>
|
Errors & Parked Messages
|
||||||
<td class="text-end">
|
</button>
|
||||||
<span class="@(report.ScriptErrorCount > 0 ? "text-danger fw-bold" : "")">@report.ScriptErrorCount</span>
|
<div class="collapse" id="@($"{detailsCollapseId}-errors")">
|
||||||
</td>
|
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
|
||||||
</tr>
|
<table class="table table-sm table-borderless mb-0">
|
||||||
<tr>
|
<tbody>
|
||||||
<td class="small">Alarm Eval Errors</td>
|
<tr>
|
||||||
<td class="text-end">
|
<td class="small">Script Errors</td>
|
||||||
<span class="@(report.AlarmEvaluationErrorCount > 0 ? "text-warning fw-bold" : "")">@report.AlarmEvaluationErrorCount</span>
|
<td class="text-end">
|
||||||
</td>
|
<span class="@(report.ScriptErrorCount > 0 ? "text-danger fw-bold" : "")">@report.ScriptErrorCount</span>
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td class="small">Dead Letters</td>
|
<tr>
|
||||||
<td class="text-end">
|
<td class="small">Alarm Eval Errors</td>
|
||||||
<span class="@(report.DeadLetterCount > 0 ? "text-danger fw-bold" : "")">@report.DeadLetterCount</span>
|
<td class="text-end">
|
||||||
</td>
|
<span class="@(report.AlarmEvaluationErrorCount > 0 ? "text-warning fw-bold" : "")">@report.AlarmEvaluationErrorCount</span>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
<tr>
|
||||||
|
<td class="small">Dead Letters</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span class="@(report.DeadLetterCount > 0 ? "text-danger fw-bold" : "")">@report.DeadLetterCount</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Parked Messages</h6>
|
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Parked Messages</h6>
|
||||||
@if (report.ParkedMessageCount == 0)
|
@if (report.ParkedMessageCount == 0)
|
||||||
{
|
{
|
||||||
<span class="text-muted small">Empty</span>
|
<span class="text-muted small">Empty</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="badge bg-warning text-dark">@report.ParkedMessageCount</span>
|
<span class="badge bg-warning text-dark">@report.ParkedMessageCount</span>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
<input type="text" class="form-control form-control-sm" placeholder="Search..."
|
<div class="input-group input-group-sm">
|
||||||
@bind="_searchTerm" @bind:event="oninput" @bind:after="ApplyFilter" />
|
<input type="text" class="form-control form-control-sm" placeholder="Search..."
|
||||||
|
@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">×</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="alert alert-warning" role="alert">
|
<div class="card shadow-sm" style="max-width: 480px; width: 100%;">
|
||||||
<h5 class="alert-heading">Not Authorized</h5>
|
<div class="card-body p-4">
|
||||||
<p class="mb-0">You do not have permission to access this page. Contact your administrator if you believe this is an error.</p>
|
<h4 class="card-title mb-3 text-center">ScadaLink</h4>
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-center">
|
||||||
|
<a href="/" class="btn btn-outline-primary btn-sm">Return to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/" class="btn btn-outline-primary btn-sm">Return to Dashboard</a>
|
|
||||||
</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;
|
||||||
|
}
|
||||||
@@ -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']")
|
||||||
|
|||||||
Reference in New Issue
Block a user