diff --git a/backups/buildathon-20260812-133210.gwbk b/backups/buildathon-20260812-133210.gwbk new file mode 100644 index 0000000..812c648 Binary files /dev/null and b/backups/buildathon-20260812-133210.gwbk differ diff --git a/dist/AlarmDashboard.zip b/dist/AlarmDashboard.zip new file mode 100644 index 0000000..dec3866 Binary files /dev/null and b/dist/AlarmDashboard.zip differ diff --git a/dist/INSTALL.md b/dist/INSTALL.md new file mode 100644 index 0000000..53af306 --- /dev/null +++ b/dist/INSTALL.md @@ -0,0 +1,129 @@ +# Alarm Dashboard — install guide + +A single Perspective dashboard that scores an alarm system against ISA-18.2 metrics +(rate, flood, chattering, standing, priority mix) and shows what to fix first. + +Ships as one file: `AlarmDashboard.zip` (a standard Ignition project export). + +## Requirements + +- Ignition **8.3** or later, with the **Perspective** module. +- An **alarm journal** profile that stores events (Config → Alarming → Journal). + Any name, any datasource, any table prefix. +- Nothing else. No third-party modules, no extra database, no tags, no scripts to + install on the gateway, no images to copy. + +## Install + +1. Gateway web UI → **Config → Projects → Import Project** (or Designer → + **File → Import Project**), choose `AlarmDashboard.zip`. +2. Name the project anything you like — the project name is not referenced + internally, so `AlarmDashboard`, `PlantAlarms`, or a site code all work. +3. Open the dashboard: `http://:8088/data/perspective/client/` + +That is the whole install on a gateway with one alarm journal. + +## If the gateway has more than one alarm journal + +Ignition rejects a journal query that does not name a profile when several exist +(`IllegalArgumentException: No alarm journal profile specified`), so the dashboard +must be told which journal to read. The dashboard will show this in its header +error chip, naming the fix. + +Designer → **Project Library → AlarmDashboard → alarms**, set the constant at the +top of the script to the profile name from Config → Alarming → Journal: + +```python +JOURNAL_NAME = "PlantJournal" # "" = single-journal gateway, let the gateway resolve it +``` + +Save the project. This is the **only** install-time setting in the whole project. + +## Area grouping — match the site's tag structure + +Every per-area metric (Areas tab, the area filter, a flood's "top area", per-area bad +actors) groups on **one folder of the alarm's tag path**. Two settings at the top of +Project Library → `AlarmDashboard` → `calc` control it. + +`AREA_SEGMENT` picks which folder, counting from the top of the tag path: + +``` +prov:FW:/tag:PS/EAS/PMP1/Alarms/Change-of-State:/alm:active + │ │ + │ └─ AREA_SEGMENT = 1 -> "EAS" (the individual site) + └──── AREA_SEGMENT = 0 -> "PS" (the site type) <- default +``` + +`AREA_NAMES` maps the raw folder to the name shown in the UI. **Several codes mapping +to the same name are summed into one area** — that is how the pump station variants +roll up: + +```python +AREA_SEGMENT = 0 + +AREA_NAMES = { + "EST": "Elevated Storage Tank", + "PS": "Pump Station", + "PSR": "Pump Station", # rolls up + "PSR2": "Pump Station", # rolls up + "PSR_STP": "Pump Station", # rolls up + "LS": "Sewage Lift Station", + "VS": "Valve Site", + "FWC": "Customer Cities - Fresh Water", + "WWC": "Customer Cities - Waste Water", +} +``` + +Folders not listed appear exactly as they are in the tag path, so a new site type shows +up as its raw code rather than vanishing — add it to `AREA_NAMES` when you see it. A tag +path with fewer folders than `AREA_SEGMENT` falls back to its first folder. + +### What the Source / Alarm columns show + +The alarm identity in every Source column (Bad Actors, Pareto) and the Journal's Alarm +column is **the rest of the path after the area folder**, so two sites running the same +alarm are distinguishable: + +``` +prov:FW:/tag:PS/EAS/PMP1/Alarms/Change-of-State:/alm:active + └─ area: Pump Station + └──────────────────────────────────── Source: EAS/PMP1/Alarms/Change-of-State/active +``` + +`AREA_SEGMENT` drives this too — the label always starts one folder after the area. If an +alarm has a **Display Path** configured, that is used verbatim instead, which is the way +to override any of this per alarm. The raw journal source string is preserved untouched +behind the scenes as the drill-down key, so click-through to source detail is unaffected. + +## Settings worth checking on the customer's journal + +- **Minimum priority** should be `Diagnostic` (store everything). If the journal + drops low-priority events, the ISA 80/15/5 priority-mix score reads as badly + skewed toward high-priority alarms through no fault of the site. +- **Journal retention**: the dashboard's range presets go out to 7 days, so keep at + least that much history for the trend and comparison ("vs previous period") views. + +## What the dashboard reads, and what it never touches + +Reads, through the alarm API only: + +- `system.alarm.queryJournal` — all history, KPIs, trends, bad actors, journal table +- `system.alarm.queryStatus` — currently active / unacked / standing alarms +- `system.alarm.getShelvedPaths` — shelved count + +It issues **no SQL**, holds **no datasource reference**, reads **no tags**, and needs +no `system.db.*` access — so it cannot be broken by a schema, table-prefix, or +tag-provider difference between sites. Plant areas are derived from the alarm's +source path, so they follow whatever tag folder structure the site already uses. + +## Verified before release + +- Imports via the gateway API onto a project name it has never used, starts with no + errors, and serves its Perspective client (HTTP 200). +- Strict project lint: 0 failures, 0 warnings (22 views, 32 style classes). +- Data layer verified live against a real journal: bundle computed with no error, + full KPI/health output, all plant areas resolved. +- Multi-journal behavior tested on a gateway with two journal profiles: unnamed + query fails as documented above, and setting `JOURNAL_NAME` restores full data. +- Export scanned for environment coupling: no company branding, no datasource names, + no tag paths, no absolute paths, no host addresses, no gateway image references. diff --git a/exchangeResources/Exchange+Resources+Style+Guide.md b/exchangeResources/Exchange+Resources+Style+Guide.md new file mode 100644 index 0000000..f5e18ae --- /dev/null +++ b/exchangeResources/Exchange+Resources+Style+Guide.md @@ -0,0 +1,1039 @@ +## Quick Reference + +||||**Perspective**|**Vision**| +|---|---|---|---|---| +|||View / Window Nam|es*
View Name|Window Name| +|||Template Names*||Template Name| +|||Properties|propertyName|propertyName| +|**Viliti**||Component Names|ComponentName|ComponentName| +|**suazaon**||Page Names|page-name|| +|||Fonts|Use font defaults|Use font defaults| +|||Theming|Built-In Theme Colors|| +|||Style Class|style-class|| +|**Resource Str**|**ucture**||exchange// - resourc
Exchange// - resourc|es that start lowercase
es that start uppercase| +||Project N|ames|project-name|| +|**Project**|Project Ti|tles|Project Title|| +|**Tags / UDTs**|Folders a|nd Tag Names|Exchange/ResourceName/TagFolder/Ta
Exchange/Resource Name/Tag Folder/T|gName or
ag Name| +|**Named**|Query Na|mes*|Query Name|| +|**Queries**|Paramete|r Names|parameterName|| +||Tabs v.s.|Spaces|Tabs|| +||Project Li|brary|exchange.folder.script
exchange.folderName.scriptName|| +||Variable|Names|variableName|| +||Function|Names|functionName|| +|**Python**|Class Na|mes|ClassName|| +||Single-Lin|e Comments|# Single-Line Comment|| +||Multi-Line|Comments|# Multi-Line
# Comments|| +||Single Lin|e Doc Strings|"""Single-Line Documentation String."""|| +||Multi-Line|Doc Strings|Google’s Python Style Guide|| +||Other co|de style choices|PEP-8|| +||Table Na|mes|ex__table|_name| +|**Database**|ID Colum|n|id|| + + + + + + +OG tgritiortet
+ +2 + +VERSION 1.1.0 + +||Foreign Key Column Name|foreign_table_name_id| +|---|---|---| +||Column Names|column_name| +|**Alarm**
**i**|Names and Folders*|Exchange/Resource Name/Alarm Pipeline| +|**Notification**
**Pipelines**|Custom Properties|propertyName| +||Names and Folders*|Exchange/Resource Name/SFC Name| +|**SFCs**|Chart Parameters|parameterName| +||Block Names|BlockName| +||Names and Folders*|Exchange/Resource Name/New Group| +|**SQL Bridge**|Item Names*|Item Name| +||Names and Folders*|Exchange/Resource Name/New Report| +|**Reports**|Parameter Names|ItemName| +||Component Names|ComponentName| +|**WebDev**|Names and Folders|exchange/resource-name/example-resource| +|**Image Mana**|**gement**|exchange/resource-name/image-name.jpg| +|**Translations**||word
(single-word translations)
_translation_phrase
(multi-word translations)| + + + +*Top level resources may use Title Case or PascalCase + + + + +OG tgritiortet
+ +3 + +VERSION 1.1.0 + +# Table of Contents + +|**Table of Contents**|**4**| +|---|---| +|**Ignition Exchange Best Practices**|**5**| +|General Guidance|5| +|Project Browser Resource Structure|5| +|Variable / Data Storage|6| +|Naming Conventions|7| +|Projects|7| +|Perspective|7| +|Tags|9| +|Vision|10| +|Code|11| +|Database|14| +|Named Queries|15| +|Alarm Notification Pipelines|16| +|Sequential Function Charts (SFCs)|16| +|SQL Bridge (Transaction Groups)|17| +|Reports|18| +|Web Development Module (WebDev)|19| +|Miscellaneous Designer Tools|20| +|**Before You Upload to the Exchange**|**22**| +|Things to check for:|22| +|Check the project export|22| +|Include the tags|22| +|Don’t forget database backups|22| +|Take notes of the steps taken when checking your project export|22| +|**Uploading to the Exchange**|**23**| +|Overview Section|23| +|Package Section|26| +|Helpful Notes|29| + + + + + + +i tive 300.266.7798 rf bed ,/
OU Ignition.
+ +4 + +VERSION 1.1.0 + +# Ignition Exchange Best Practices + +When developing Exchange Resources, following the styles outlined below will help with consistency of naming conventions. + +Having resources conform to these standards helps make resources easier to understand, explore, implement, and upgrade. These best practices should be adhered to unless it is not possible for the resource in question. We encourage you to follow the naming conventions outlined here, however these conventions are not required. **Consistent naming conventions are critical, regardless of what naming conventions you ultimately use** . + +## General Guidance + +### Project Browser Resource Structure + +exchange// - resources that start lowercase Exchange// - resources that start uppercase + +Views, Styles, Scripts, etc. **must** all be contained within an appropriately named folder. This allows easy imports into existing projects, where those project resources won’t overlap, interfere, or overwrite existing parts of a user’s project. It also allows for easy updates to Exchange resources for users when a new version is released. Look below to see which resource categories should start lowercase and which should start uppercase. + + + + +Project Browser ax
a allly
>- fim Exchange
~ && Sequential Function Charts
> fam Exchange
~ Bi scripting
& Gateway Events
~~ f& Project Library
> {@ exchange
~ @ Perspective
& Session Events
~ i Styles
> fmm exchange
~ Wi Views
> fam Exchange
+ + + + +o
2g Transaction Groups
> iam Exchange
& Client events
~ Bwi
_
> mm Exchange
-
y fo Templates
>i Exchange
~ & Named Queries
> fm Exchange
~ B Reports
> jam Exchange
~ @ Web Dev
b+ exchange
+ + + + +OF automationinductive — www.inductiveautomation.com8002667798 |gnitionTh AY e/
+ +5 + +VERSION 1.1.0 + +### Variable / Data Storage + +##### Temporary Client / Session Variables + +View / Window Properties + +system.util.getGlobals()['exchange']['resourceName']['clientId'] + +Temporary Gateway Variables (Persists Until Gateway Restart) + +system.util.getGlobals()['exchange']['resourceName'] + +##### Long Term Storage + +Database Tables + +Avoid + +Perspective Session Properties Vision Client Tags + +Variables that are only expected to be used by a single client / session can often be stored in properties on views or windows. For items that need to be stored even when the view or window isn’t shown anymore, the globals dictionary at system.util.getGlobals() can be a great place to store these things. Data, objects, or variables intended to be used over multiple sessions can be stored in globals as well. + +Although projects will often use Perspective session properties or Vision client tags for this type of storage, doing this for an Exchange resource limits the ability to import your resource into another project. Using just the variables suggested here will help ensure that importing your resource will be smooth when someone wants to use it in a separate project. + +A scoping note on system.util.getGlobals(): + +Vision: In a Vision client, this is a dictionary inside the client, and there’s a separate dictionary for each client. For scripts running in the client scope, they’ll access the client globals dictionary. For scripts running in the Gateway scope, it’ll access the shared Gateway globals dictionary. + +Perspective: the globals dictionary is always the shared Gateway globals dictionary. The same data is available from the Gateway scope and from sessions. + + + + +i tive 300.266.7798 rf bed ,/
OU Ignition.
+ +6 + +VERSION 1.1.0 + +## Naming Conventions + +Consistent naming helps with projects feeling cohesive, and can also help to easily identify what type of object is being used just by looking at its name for certain resources. + +### Projects + +#### Project Names + +##### project-name + +Starting in Ignition 8.1.11, these should be lowercase and use dashes for multi-word names to be URL-friendly. Project names should only include letters, numbers, hyphens, and underscores. Please do not append the version number to the end of the project name, the version number can be managed through the Exchange during the upload process. + +#### Project Titles + +##### Project Title + +These should be Human-friendly, with capitals and spaces as appropriate. + +### Perspective + +#### View Names + +##### View Name or ViewName + +Views can either be Title Case or PascalCase.. This gives view names an easy to read format. + +#### Properties + +##### propertyName + +These should be camelCase, starting with a lowercase letter. This matches the style of the built-in properties on Perspective components. + +#### View Properties + +params.publicProperty + +##### custom.privateProperty + +For embedded views, put any property that’s intended to be used to configure the view as a property in the param category. Put properties that you create for internal use inside the view itself as a property in the custom category. This separation helps make it clearer + + + + +i tive 300.266.7798 rf bed ,/
OU Ignition.
+ +7 + +VERSION 1.1.0 + +which properties will just be used internally in the view and which are intended to be settings for the view. + +#### Component Names + +##### ComponentName + +These should be PascalCase starting with a capital letter and capitalizing the first letter of each word. This provides consistency with the default component names. + +#### Page Names + +##### page-name + +These should be lowercase and use dashes for multi-word names. This puts them in a format that’s URL-friendly. + +#### Message Handlers + +exchange.resourceName.handlerName + +or exchange.resourceName.viewName.handlerName + +Unique message handler names prevent collision with existing handlers. Using exhange.resourceName as a naming convention will identify the resource in console logs as well as facilitate unique naming. This is a recommendation and not a requirement for publishing a resource on the Exchange. + +#### Fonts + +Font sizes should match the default font sizes of most components, or take advantage of styling as appropriate. Matching defaults makes the resource fit stylistically inside projects that are using Ignition’s defaults. + +#### Theming + +Supporting themes is optional, but is a nice feature for users of your resource. When supporting theming, set colors to appropriate theme colors so Ignition’s theming can change the colors displayed as different themes are selected. Refer to the Ignition documentation for Built-In Theme Colors. + +#### Style Classes + +style-class + +This should be lowercase with dashes for multi-word names. Style class naming and hierarchy should match the hierarchy of the Perspective views they belong to. If style class is used across multiple views, place the style class at the topmost folder that + + + + +i tive 300.266.7798 rf bed ,/
OU Ignition.
+ +8 + +VERSION 1.1.0 + +encompasses all views using it. If the style class is exclusively used in one view, create a folder named after the view name and place the style class within it. (See picture below for an example.) + + + + +@ Alarm Notification Pipelines
‘a Sequential Function Charts
> B scripting
~ © Perspective
& Session Events
~ fe Styles
~ fmm exchange
~ fam resource-name
~ fam another-view.name
«® only-used-in-another-view-name
> fm viewname
«® only-used-in-view-name
(® used-in-multiple-views
~ &% Views
~ fam Exchange
~ fam ResourceName
@ AnotherViewName
@ ViewName
Qy Transaction Groups
> @vision Le
B named Queries
Reports
@ web Dev
+ +### Tags + +For tag names and folders, you can use either PascalCase or Title Case. It’s nice that Ignition supports spaces in tag names and folders, and you may use this to your advantage to make your tags and tag folders more readable. However, you may find PascalCase to be a neater option for tag names. This structure also applies to UDT definitions. + +Note that spaces in tag names may lead to property names with spaces specifically when using UDT bindings in Perspective. The designer will notify of this as non-standard, but doesn’t degrade performance or affect clients in any way. + +Tag Folders and Names + +Exchange/ResourceName/TagFolder/TagName + +or + +Exchange/ResourceName/Tag Folder/Tag Name + + + + +inductive 8002667798 Th AY /
OU Ignition.
+ +9 + +VERSION 1.1.0 + +### Vision + +#### Window Names + +##### Window Name or WindowName + +Window names can be either Title Case or PascalCase. + +#### Template Names + +##### Template Name or TemplateName + +As with window names, template names can be either Title Case or PascalCase. + +#### Properties + +##### propertyName + +These should be camelCase, starting with a lowercase letter. This matches the style of the built-in parameters on Perspective components. + +#### Component Names + +##### ComponentName + +These should be PascalCase, with a capital letter and capitalizing the first letter of each word. This provides consistency with View Names and with Vision Component Names. + + + + +ProjectBronser a-x
O Fite a
£9) Alarm Notification Pipelines
1, Sequential Function Charts
> Bi scripting
> @ Perspective
im Transaction Groups
> © vision
& client Everts
+ Biwindows
~ fam Exchange
~ fam Resource Name
Another window
Ci mainwindow ©
> €3 Templates
~ fam Exchange
~ fam Resource Name
fam Another Window
> fam Main window
#€3 Template For Main Window
€3 General Template For Multiple Windows
3 Named Queries
& repors
+ + + + +inductive — 8002667798 | iti “ /
automation — www.inductiveautomation.com gnition e
+ +10 + +VERSION 1.1.0 + +### Code + +Having good names for variables, functions, and consistency with white space is important for readability and maintainability of code. + +#### Tabs v.s. Spaces + +Ignition’s editor uses tabs when the tab key is pressed. Therefore, tabs should be used for consistency, compatibility with code from other Ignition users, and maintainability. + +#### Project Library + +exchange.folder.script + +exchange.folderName.scriptName + +Scripts and folders in the project library should be all lowercase, and should be single words where possible. + +#### Variable Names + +##### variableName + +These should be camelCase, starting with a lowercase letter. This is the style used in the Ignition user manual, to have consistency with property names. Note that this differs from PEP-8. Variable names that are being used to pass data to Ignition’s API functions should match the names of the arguments in functions where it makes sense. For example, if you’re defining a variable to pass to startDate in a function, call your variable startDate. Prefixing with another name may also make sense depending on your application, like processStartDate. + +#### Function Names + +functionName + +These should be camelCase, starting with a lowercase letter. This is the style used in the Ignition user manual, to have consistency with Java method name conventions. Note that this differs from PEP-8. + +#### Class Names + +##### ClassName + +CamelCase starting with an uppercase letter. + + + + +i tive 300.266.7798 rf bed ,/
OU Ignition.
+ +11 + +VERSION 1.1.0 + +#### Single-Line Comments + +# Single-Line Comment + +Comments should start with a # followed by a space before adding the comment. Single-line comments should be placed one line above the code being explained. + +#### Multi-Line Comments + +# Multi-Line + +# Comments + +Each line should start with a # followed by a space to line up the starting word of each line. + +#### Single-Line Documentation Strings + +"""Single-Line Documentation String.""" + +Documentation strings are used for the first statement in functions, classes, and methods. These should appear after the _def_ line, have one additional indent from the _def_ line, and be wrapped in triple double-quotes on the same line as the comment itself. Each sentence should be terminated by either a period, question mark, or exclamation point. + +#### Multi-Line Documentation Strings + +"" "Multi-Line Documentation String. + +Some Description. + +Args, Returns, or Raises: + +A description of what is returned. + +Example: {foo: bar} + +""" + +Following the same guidelines as single line documentation strings, the main difference is the format (i.e., listing arguments, return format). Each description or summary line should be terminated by a period, question mark, or exclamation point. When writing more in the same comment block, there should be a blank line between the two descriptions. Each following line after the first, should start at the same indent spacing as the first quote in the first line. Each args, returns, or raises section title should end with a colon and the following description should be indented with tabs. Refer to Google’s Python Style Guide for more examples. + + + + +i tive 300.266.7798 rf bed ,/
OU Ignition.
+ +12 + +VERSION 1.1.0 + +Beginning with Ignition 8.1.32, custom project script functions that have docstrings defined in this manner are displayed via Ignition’s autocomplete. + +#### Line breaks and other code style choices + +For everything other than the items listed above, refer to PEP-8. A number of naming conventions in Ignition match PEP-8, and a number are different (like Variable Names and Function Names). When a choice isn’t covered in this style guide, use PEP-8 as a fallback reference. + + + + +eee B= % exchange. resource nane. script
- 2 | selass classname: ~) fF © classine
(®) Nar Neicaton Pipcines 2° *"Single-Line Document Example explaining Classtane.**~ > fnctiontnclass
~ 2,Biseriing* Sequential Funcion chars 345 dot funetdontnclass(self1 Singie-Line coment explaining the Line of code below it ‘ functéonane
Ei Gateway Evers 6 print Hello world")
~ Bi Project ubrary 4
~ fa© exchangeresmuce ame ioef Functiontne(argueentOne,““"Wults-Line DocunentationargumentTwo)String
SSM srocner socuentation coment that
~ @ aPerspecvej Session Everts u3513, Arsewraps to another Tine
> bs soles 16 sxoumentOne: A boolean axsinent
» Views v7 SxourentTwo: Another boolean azgunent.
i Transacton Grou= 198 eturs
+ @vision cy Returns either True or False if the two argurents are both Trve,
» Bi named queries a
B reports 22 Classtane().functiontnctass(
B
26 Af srgumentone and argunenttwo:
Dretum True
22 alee
29” return False
2”
+ +### Database + +Some database systems are case sensitive and some do not allow upper case in table or column names by default. The following standards will ensure consistent naming regardless of database platform. In general, use snake_case for naming all database objects. This means all lowercase with underscores between words for human readability. Hyphens or dashes are not accepted as names by all database platforms. Avoid numbers, sql keywords and special characters in names. + +#### Schema Names + +ex_resource_name + +Schema names should start with ex followed by the Exchange Resource name to avoid sql import/create conflicts in pre-existing schemas. + + + + +; s .
Ce Ignition’ /
automation — www.inductiveautomation.com by inductive automation e
+ +13 + +VERSION 1.1.0 + +#### Table Names + +ex__table_name (ex_cm_contacts for example) Table names should start with "ex_" followed by the Exchange Resource's abbreviated project name to avoid sql import/create conflicts in pre-existing schemas. + +#### ID Column + +##### id + +First auto-increment primary key column in all tables should be “id”. Being a primary key also allows Ignition’s database query browser to support the table fully, allowing users to edit fields with Ignition’s built-in tools. A consistent name for the primary key in all tables will aid in creating reusable components. + +#### Foreign Key Column + +foreign_table_name_id + +Foreign key column names should be prefixed with the table name that the foreign key is referencing followed by “_id”. Thus making it easy to understand what table the foreign key ID is referencing. + +#### Column Names + +##### column_name + +Use lowercase table names with no numbers or spaces.General Recommendations Stay consistent with column and table names. For example, do not use “order_no” for a column in one table and “order_number” in another table or “cust_number” in one place and “customer_number” in another. + + + + +err | Table Name: |my_table Schema: test
gr Charset/Colation: [Defaulth v] [DefauitCo ©] Engine: {innoDa v]
Column Name Datatype PK NN UQ B UN Z AI G__ Defaul
vid INT 4MHUOoDooorDa
> column_a VARCHAR(45) OQodcooonouod
> column_b VARCHAR(45) Ooudcdoaoogod
© column_x VARCHAR(45) Ooodgoangaodag
> column_y INT OQoOoOoodgonvgunu ug
oooao00n0odao gd
+ + + + +Tay inductive oe cnson Ignition
+ +14 + +VERSION 1.1.0 + +### Named Queries + +#### Query Names and Folders + +##### Query Name or QueryName + +Named queries can be either Title Case or PascalCase. + +#### Parameter Names + +##### parameterName + +Use camelCase, starting with a lowercase letter. These are often used in scripts and component properties, so matching Python variable names and component property name convention makes sense. + + + + +Project Browser ax
a a Exchange/ResourceName/QueryName
@ Alarm Notification Pipelines % Settings | Authoring | © Testing
4 Sequential Function Charts Database Connection Query Type
> G scripting + S| Query ’
> @ Perspective
8 Transaction Groups [yrameters
> © Vision Type Name Data Type +
~ B Named Queries value myValueX String 8
~ fmm Exchange Value myValueY Inta
PP Cercenare ~Reportsfam ResourceName |
@ web Dev
Query
1 SELECT column_a, column_b
2 FROM my_table
3 WHERE column_x = :myValueX
4 AND column_y = :myValueY
5
+ +### Alarm Notification Pipelines + +Pipeline Names and Folders + +Exchange/Resource Name/Alarm Pipeline + +or + +Exchange/ResourceName/AlarmPipeline + +Alarm pipeline names can be either Title Case or PascalCase. + + + + +inductive — 800.2667798 | iti \\ /
RICMELG | Seneca gnition\.
+ +15 + +VERSION 1.1.0 + +#### Custom Properties + +##### propertyName + +Use camelCase, starting with a lowercase letter. This matches the convention used in + +built-in properties when accessed through Alarm Pipelines in expression or script blocks. + + + + +Project Browser Bl _ X Pipeline Blocks
OF A
~~ @) Alarm Notification Pipelines r
~ fam Exchange
PT Catrreinenae #4~Sequentialfam ResourceName Function Charts | Is itd Tapo 0
> Bi scripting pi seerrovery[E
> @ Perspective ‘linus
Tag Browser ax
+Q CS | defauit vie
+ +### Sequential Function Charts (SFCs) + +#### SFC Names and Folders + +Exchange/Resource Name/SFC Name + +or + +Exchange/ResourceName/SFCName + +SFC names and folders can be either Title Case or PascalCase. + +#### Chart Parameter Names + +##### parameterName + +Use camelCase, starting with a lowercase letter. These are often used in scripts and component properties, so matching Python variable names and component property name convention makes sense. + +#### SFC Block Names + +##### BlockNames + +Use PascalCase, starting with a capital letter and capitalizing the first letter of each word. Keep block names short and concise and avoid using multiple word names when possible. + + + + +OF inductiveautomation —— 8002667798 www.inductiveautomation.com | gnitioniti “ e/
+ +16 + +VERSION 1.1.0 + + + + +Project reer ax
Q 4A
~ @ Alarm Notification Pipelines 7 ; 7
fam Exchange
> fm ResourceName ®
~~ ¢y Sequential Function Charts
~ fam ResourceName
| a SFCName 2 BlockName
> EB scripting
Tag Browser+- Q S defautt avesvii |, >
Tags UDT Definitions 4
Taa Value
+ +### SQL Bridge (Transaction Groups) + +Transaction Group Names and Folders + +Exchange/Resource Name/New Group + +or + +Exchange/ResourceName/NewGroup + +Transaction group names and folders can be either Title Case or PascalCase. + +#### Item Names + +Item Name or ItemName + +Item names can be either Title Case or PascalCase. + + + + +Project ronser ax
a A Transaction Group > Enabled © Disabled
> . Execution Disabled
@ Alarm Notification Pipelines
> &% Sequential Function charts eee
» scripting ItemS Name SoureeV... | Latched... | Mode Target Name DataType ‘Properties
> @ Perspective ® OPCTagitem Item NAN NAN UseUse group’sgroup's modemode Read-onlyOPC_Item ‘StringString
+ 2 Transaction Groups z
[]> ©~ ~fapVisionCaltamcocenfapExchange Resource Name | unrItemAbays NameExpression Items SourceValueignore trigger (®) Latched Value | TargetName Data Type Properties i
BB Named queries Friggered Expression Items
Reports Tem Name. Source Value | Latched Value | Target Name Data Type Properties
© Expression Item NA NA Read-only ined
+ + + + +inductive 2002667798 | iti ‘ /
Nee Soman gnition’s
Dy inductive automation
+ +17 + +VERSION 1.1.0 + +### Reports + +Report Names and Folders + +Exchange/Resource Name/New Report or + +Exchange/ResourceName/NewReport + +Report names can be either Title Case or PascalCase. + +#### Parameter Names + +ItemName + +Report parameters are uppercase with no spaces for multi-worded names. This format matches the default naming convention when adding new parameters. + +#### Component Names + +##### ComponentName + +Component names should be PascalCase, starting with a capital letter and capitalizing the first letter of each word. + + + + +a al oy
> aa Sequential Function Charts,
> Bscrtie L
+ @ respecte
G Session Events .
+ Be+ Viewste tare .EB 7
+ menrnnane
@ Anotherview'iame
+ Dviewvame
+ Broct @ = 4
+ +### Web Development Module (WebDev) + +#### Source Names and Folders + +##### exchange/resource-name/example-resource + +Web Dev should be lowercase with dashes for multiple words. This format ensures that referencing the resource is web friendly. + + + + +inductive — 800.2667798 | iti \\ /
automation — www.inductiveautomation.com gnition e
+ +18 + +VERSION 1.1.0 + + + + +Poet rower ax
a TA MtPuenos dose +) EQerbied (itera TPS Ree eraton .
> @) Alarm Notcabon peines def doGet~ request, session)
»> dyB Sequenvalscripting ancton chars Respondusing anytoofantheincomingfollowingHTTPkeysrequest. Formlate your response as a dictionary
> @ Perspective hte’ HTML source as a string
»» @vsionSj Tansacton Groups son’ A python dictionary sich will be encoded as ‘application/json’ data
B named queries ile’ A file path to send as the response
» G reports “bytes” A byte[] to send back. Mime type will be ‘application/octet-stream’
> @weboev Af not specified
7 moctorae
Vm roacecune esponse’ Any Any 3 type of f plainplain texttext response
contentType* The mine type of the response. Needed only if ambiguous
Aagunents
Tequest: A dictionary with information about the inconing web request
context: A reference to the Gateway's context object
data: The data on the zequest. If the content type 45 application/}son,
val be a Python structure (list or dictionary). If not, will either be
headersPlain(dict):textAvalues dictionaryor a rawwerebytereturnedof array.headerfor: valuethe sanepairs.header,If multiplevalues will be in a tuple.
parans (dict): A dictionary of URL paroneters. If multiple values were
returmed for the sane paraneter, values will be in a tuple
xenainingPath (stz): The xenainder of the URL after this resource
renotendor (str): Returns the IP adaress of the client.
renoteliost (stx): Returns the fully qualified nane of the client
schene (Stz): Retuans the nane of the schene used to make this request
de. “http’ or ‘netps
servietRequest: The underlying Java HttpServletRequest object
ServletResponse: The underlying Java HttpServletResponse object.
session: A dictionary that will be persistent across multiple requests
‘ron the sane session. If authentication is required, will have a “user
attribute containing information about the authenticated user, and a
retryAttenpts” attribute with the nusber of attegpts nade
2 retum {"html'; ‘")
@ crampleresource x
+ +### Loggers + +Logger names should use dot separation to create a logging hierarchy, indicating where in the project the logger is used. To match the convention of other loggers used in the Ignition gateway, any logger name elements preceding the final period should be camelcase, and the logger name at the end should be PascalCase. Keep in mind that only the final logger name is shown on the logs, but a mouseover event will show the entire logger name. + +It is recommended that loggers are called via scripts in the Project Library instead of directly on views, which can be difficult to find. In other words, scripts containing loggers should be called from views. + + + + +exchange.resourceName.parseData.ConversionError Time Message
EE ConversionError 28,Jun2023 10:32:03, Cannot parse variable to expected type: Str
+ + + + +inductive 800.2667798 | niti n,/
automation — www.inductiveautomation.com g Dy inauctiveI 10automation e
+ +19 + +VERSION 1.1.0 + +Logger naming convention + +exchange.resourceName.LoggerName + +exchange.resourceName.script.scriptName.LoggerName + +For projects with a large number of scripts in the project library, it may be appropriate to show an abbreviated path to the script. For instance, if the path to the script is “Exchange/MyResource/Utilities/ParseData”, a useful logger name would be “exchange.resourceName.script.utilities.parseData.ConversionError” + +### Miscellaneous Designer Tools + +#### Image Management + +exchange/resource-name/image-name.jpg + +Images uploaded using the Image Management should be uploaded under a folder hierarchy that starts with a folder named "exchange" followed by another folder named after the resource name. Folders and image names should be lowercase and use dashes between words. Image names should be meaningful and accurately represent the image. + + + + +ll Image Management - o x
a@aciaQ@ @iF m®
Current Folder _exchange/resource-name/
e
ignition-ic..
Q Tip: You can drag images and folders into this window for easy uploading.
+ +#### Translation Manager + +word (single-word translations) + +_translation_phrase (multi-word translations) + +If the translation key is a single word, the key should match the word being translated. If translating a group of text, start the key with an underscore and replace spaces in the key with underscores. + + + + +inductive 8002667798 | iti ,/
AULoMatION _wmcntreetomaon.com gnition’.
by indetve automation
+ +20 + +VERSION 1.1.0 + + + + +‘Trandlation Manager 5x
Languages Translation Terms Qu
(All) + Key English (Alternate) Spanish +
English | @® _translation_phrase This is a multi-word text translation. Esta es una traduccion de texto de varias palabras. |»
[Spanish Dm word word palabra
a
a
*
+ + + + +F ‘ eye
inductive 8002667798 | nition: /
automation — wwwanductiveautomation.com g by indetve automation e
+ +21 + +VERSION 1.1.0 + +# Before You Upload to the Exchange + +When your resource is finalized, there are a few steps that should be taken before uploading the resource to the Exchange. The purpose of checking your own resource is to make the process as smooth as possible and get your resource published on the Exchange in a timely manner. Following the steps below will insure this and help mitigate uploading incomplete or broken resources which will result in the resource being rejected until the issues are resolved. + +### Things to check for: + +#### Check the project export + +If the resource to be uploaded is a project export, check that everything that needs to be included in the project was exported correctly. This can be done by importing the exported project into a brand new project and verifying that the resources works as expected. + +#### Include the tags + +Does the resource require tags and/or user defined tags (UDTs)? If the resource requires tags, make sure to export the necessary tags and/or UDTs. Then follow the same procedure as Check the project export and make sure all the tags needed in the project are included in the tag export. + +#### Don’t forget database backups + +Does the resource reference a predefined table structure or include example data? Include the necessary database dumps when uploading to the Exchange and make sure to state the database vendor (i.e., MySQL, MSSQL, PostgreSQL, etc.) being used in resource description and custom installation instruction step when uploading to the Exchange. + +Take notes of the steps taken when checking your project export + +When uploading your resource to the Exchange, there will be a section for Custom Installation Instructions. This is where you will give step-by-step instructions on how to set-up your resource to ensure that users get started correctly. It is important to understand that the Ignition experience level of users downloading resources from the Exchange vary from beginners to intermediate so it is important to be as clear and concise as possible. + + + + +i tive 300.266.7798 rf bed ,/
OU Ignition.
+ +22 + +VERSION 1.1.0 + +# Uploading to the Exchange + +The following steps go over best practices for uploading a project to the Exchange. For each section of the Exchange upload process, please fill out the following to the best of your ability. + +### Overview Section + +#### Visibility + +Unless the project is a private project and not intended for public use, it is recommended that the visibility of the project is set to Public (Visible to everyone). + + + + +Visibility Public (Visible to everyone) ~
publish
+ +#### Title + +The title should be short, concise, and identify the functionality and/or purpose of the project being uploaded. In most cases, if this is a project being uploaded, the title can match the project title given for the project. The resource title should be formatted in Title Case. + + + + +Title Perspective User Management fa]
+ +#### Tagline + +The tagline is a short description that accompanies the title to give users a better general understanding of the functionality of the project. This should be a one sentence, short description of the project and should not match the title. + + + + +Tagline User Management tool for Perspective projects
+ +#### Description + +The description expands upon the tagline to give a more detailed explanation of the project, how it is meant to be used, what types of people would benefit from this resource, etc. + + + + +inductive — 200.2667798 laniti \\ /
automation — wwinductiveautomation.com gnitione
+ +23 + +VERSION 1.1.0 + + + + +Description | Bo J 32 i=
This Perspective User Management tool allows users to add, edit, and remove users and roles
from the Gateway.
c a detailed description of your resource. Include information likehow you
envision it being used, what types of people might benefit from this package, any
relevant industries, etc
+ +#### Resource Type + +Choose the best resource type from the drop down that best describes the type of resource that is being uploaded. Then choose a skill level that best fits the complexity of installation and use. + + + + +Resource Perspective View ’ Beginner +
Type
Choose a primary resource type that Choose a skill level that best describes
best describes your resourc the complexity of installation and use
+ +#### Background Image + +The background image is an optional step for adding a color and design to the background of the Exchange resource card and page. + + + + +Backgroundvena © 960035930
ustomize the background of your resource with a colorful desi
+ +#### Category + +The category section is used for filtering Exchange resources. In order to ensure that the resource being uploaded gets proper exposure on the Exchange, select up to 3 categories that best match the resource provided. + + + + +inductive — 8002667798 | iti “ /
Nee Soman gnition’s
by inductive automation
+ +24 + +VERSION 1.1.0 + + + + +Category Alarming Analytics Connectivity Dashboard
Diagnostics Edge Computing Enterprise HMI
vortacs HOT/MQTT MES Maintenance
Mapping/GIs Mobile Monitoring
Reporting SCADA Scripting Security
Simulation Trending/Charting Utility
Resources are displayed by category. Select up to
3 categories.
+ +#### Contact the Developer + +Enabling the Contact the Developer functionality allows users to directly email the resource developer with any questions or concerns about the resource. There are two options to choose from. The first option being “Let contributors contact me about all my exchange resources”, will allow all visible resources uploaded in your account to be available for contact. The second option “Let contributors contact me about this resource”,will only enable this feature for the resource that is currently being uploaded. + +- **Note** : Your email address will not be shown to users that are attempting to contact you. If someone chooses to contact the developer, you will receive an email with the question and the user’s email address. You will then have the ability to contact the user directly. + + + + +Contactthe —_Let contributors contact me about all my exchange resources ©
Developer
Let contributors contact me about this resource ©
This feature allows contributors to contact you via email directly from the resource page
+ +#### Tags + +Tags are another way for the resource to gain exposure on the Exchange. With the ability to create up to 10 tags, these tags will help the resource show up in the results when those tags are used as keywords in the search bar. + + + + +inductive 8002667798 | iti AY /
automation — www.inductiveautomation.com gnitions
+ +25 + +VERSION 1.1.0 + + + + +Tags
: Tag: ke y s( mo arc Cc ul 10 ta
User Management x Users x
+ +#### Images & Screenshots + +Images and Screenshots, though optional, are highly encouraged to be included with the resource. Images, for example project views, give users a better understanding of the project that cannot be explained in the title and description alone. + + + + +Images&
Screenshots
@
Drop files here to upload
(up to 10mb)
Or choosea fle
+ +### Package Section + +The package section is where the resource files will be uploaded and information about Ignition and module requirements are defined. + +#### Version + +Unless uploading a subsequent package version, this package version should be Version 1.0.0 and any subsequent updates will be auto-incremented unless specified by the uploader. + + + + +Version 1 oO oO
+ +#### Ignition Platform + +The Ignition version should be the minimum version needed in order for the package being uploaded to be installed and run correctly. If unsure, select the Ignition platform version in which the package was developed with. + + + + +inductive — 8002667798 | iti “ /
automation — wwwinductiveautomation.com gni Ione
+ +26 + +VERSION 1.1.0 + + + + +Ignition Platform 81 * 3 ~
Choose the minimu f Ignition required
+ +#### Release Tagline + +The release tagline summarizes the changes that were made to the package version being uploaded. A title will be needed for the initial release version as well. + + + + +Release Tagline _nitial release fa}
Create a short but descriptive tagline to help identify what changes were made in this version.
+ +#### Release Notes + +The release notes give a more detailed explanation of the changes in the package version. Use this area to expand on the release tagline, give more information about the version, and explain how this version will impact the resource. The release version is required even for the initial release version, but may not include as much detail as subsequent version releases of the package. + + + + +ReleaseNotes = B J i= 15
Initial release
Listwere madedetailed andreleasehow theynotes willexplainingimpactwhat changedin this version. Think about what improvement
the resource.
+ +#### Required Modules + +If the resource requires any modules to be installed in order for the package to work correctly, select the required modules by expanding the accordion for the Inductive Automation Modules, Cirrus Link Solutions MQTT Modules, and/or Sepasoft, Inc. MES Modules. + + + + +Required . .
Modules Inductive Automation Modules Vv
Optio
Cirrus Link Solutions MQTT Modules for Ignition Vv
Sepasoft, Inc. MES Modules for Ignition Vv
Choose any modules that are required when using this resource
+ + + + +inductive 8002667798 | iti AY /
AULoMatION _wmcntreetomaon.com gnition’.
by indetve automation
+ +27 + +VERSION 1.1.0 + +#### Maker Edition + +Maker Edition is a non-commercial, personal use only version of Ignition which is intended for hobbyists, students, and individuals. If the package being uploaded is intended for Maker Edition or will run in Maker Edition, enabling this option will let users know the package can be installed for their Maker Edition version of Ignition. + + + + +Maker Edition Let people know this resource is Maker Edition compatible.
fora This optionis unavailable for resources requiring unsupported Maker Edition modules
Learn more about Ignition Maker Edition™
+ +#### Other Requirements + +If there are other required items that need to be installed for your resource to work they need to be listed in the Other Requirements section. If there are no additional requirements for your resource this field can be left blank. + + + + +Other +
Requirementspron List any other extemal or custom requirements as separate line item
+ +#### Package Files + +Upload all files here that will be needed to ensure your project works as expected. This includes your project backup, tag files, etc. + + + + +Package Files

Drop files here to upload
(up to 10mb)
Or choose a file
Uploaded files
UserManagement.zip 08.97KB X
Identifying common file types will help to reducethe complexity of installatior
+ +#### Custom Installation Instructions + +Detailed instructions are highly encouraged for resources as they assist the users that download your resource in understanding how to utilize your resource. These instructions + + + + +inductive 8002667798 | iti AY /
Nee Soman gnition’s
Dy inductive automation
+ +28 + +VERSION 1.1.0 + +should include any necessary steps around uploading your resource and any required modules as well as instructions on how to utilize your resource once it is installed. + +- **Note:** If you already have detailed documentation for your resource please note it here and include the documentation file in the Package File upload. + + + + +Custom Installation Instructions Optionc
**Please Note: Managing users and roles outside of the Gateway may allow others to exploit the
security of your Gateway. Please refer to our Ignition Security Hardening Guide on ways you can
secure the Perspective User Management view from being used for malicious reasons.
https: //inductiveautomation.com/resources/article/ignition-security-hardening-guide#step--define-
application-security .
Provide clear and concise documentation to help other people understand how to install yo
files.esource.InstallationThis is onlyinstructionsnecessaryfor knownif you havefile typesselectedwill be “other”automaticallyas your filetypegeneratefor any uploaded
+ +### Helpful Notes + +- The Exchange will automatically generate a readme file from the information you have entered in the Resource Description and Custom Installation Instructions during the upload process + +- If publishing your resource in a non-English language we encourage you to include a dropdown that allows your project to be displayed in your native language as well as English. This will increase community access to your project. + +- As shown above, capitalized top level resources can be Title Case or PascalCase. Title Case increases readability, matches the default format from the Ignition Designer when creating new resources, can differentiate the resource name from the sub-resources like component names, and creates files on disk with spaces. PascalCase avoids spaces in filenames on disk, is still fairly readable, matches resource names from sub resources like component names, and is preferred by some Ignition users. Both are acceptable choices. + + + + +inductive — 8002667798 | iti “ /
Nee Soman gnition’s
by inductive automation
+ +29 + +VERSION 1.1.0 + diff --git a/exchangeResources/Exchange+Resources+Style+Guide.pdf b/exchangeResources/Exchange+Resources+Style+Guide.pdf new file mode 100644 index 0000000..56fea04 Binary files /dev/null and b/exchangeResources/Exchange+Resources+Style+Guide.pdf differ diff --git a/exchangeResources/PUBLISHING.md b/exchangeResources/PUBLISHING.md new file mode 100644 index 0000000..d7da198 --- /dev/null +++ b/exchangeResources/PUBLISHING.md @@ -0,0 +1,202 @@ +# Publishing a resource to the Ignition Exchange + +How to take a conforming Ignition project and get it onto the Exchange: what file +format to upload, how to produce and verify it, and what to put in every field of +the upload form. + +Companion to [`exchange_lint.py`](exchange_lint.py), which tests whether a project +follows the conventions, and to the +[Exchange Resources Style Guide](Exchange+Resources+Style+Guide.md), which is the +source for everything here. + +--- + +## 1. What you upload + +A **project export `.zip`** — the file Ignition produces from Designer's +File → Export, not a gateway backup. + +| File | Upload as | Notes | +|---|---|---| +| `.zip` | Package file | The project export. The main artifact. | +| `README.md` (or PDF) | Package file | Separate upload — see the warning below. | +| Tag export `.json` | Package file | Only if the resource needs tags/UDTs. | +| Database dump `.sql` | Package file | Only if it needs a schema or seed data. | +| Screenshots `.png` | Images & Screenshots | Optional, strongly encouraged. | + +> **Never upload a gateway backup (`.gwbk`).** It contains your entire gateway — +> datasource credentials, journal profiles, tags, every other project. It is the +> wrong artifact and it leaks configuration into a public resource. + +The Exchange accepts multiple package files, up to 10 MB each. + +### The README will probably not be inside the zip + +Ignition's project export serialises **recognised project resources** — each is a +directory containing a `resource.json`. A loose `README.md` at the project root +is not a project resource, so the export is expected to drop it. + +This is reasoning from how the export works, not a verified observation. **Unzip +your export and check** before uploading. Either way the guide tells you to +upload documentation separately: + +> **Note:** If you already have detailed documentation for your resource please +> note it here and include the documentation file in the Package File upload. + +Note also that `exchange_lint.py`'s `E-UP-DOCS` rule checks the working +directory, not the shipped package. A passing lint does not prove the zip +carries your docs. + +--- + +## 2. Before you package + +### Run the conformance checker + +```bash +python3 exchangeResources/exchange_lint.py +``` + +Zero failures is the bar. Warnings are naming conventions the guide calls +"encouraged, not required" — review them and decide, rather than silencing them +by contorting the project. Document any you deliberately keep. + +### Work the guide's own checklist + +The guide names four pre-upload checks. Three are often N/A for a +journal- or tag-only resource; the first never is: + +1. **Check the project export.** Import the exported zip into a brand-new + project and confirm it works. Then import it into a project that *already has + content* — that is the case the namespacing rules exist for, and the only way + to prove nothing collides. +2. **Include the tags.** If the resource needs tags or UDTs, export them and + verify the export covers everything the project binds to. +3. **Don't forget database backups.** If it expects tables or seed data, include + the dump and name the vendor (MySQL / MSSQL / PostgreSQL) in the description + *and* in the install instructions. +4. **Take notes while you test.** Those notes become the Custom Installation + Instructions field. Readers range from beginner to intermediate. + +### Strip anything internal + +Search the project for company names, project codenames, internal hostnames, +ticket numbers and placeholder text — in **user-visible strings**, not just +comments. The linter does not check content, only names and structure. + +```bash +grep -rniE "yourcompany|internal|localhost|todo|fixme|demo only" +``` + +Check rendered label text too; a `props.text` value is easy to miss in JSON. + +--- + +## 3. Produce the export + +**Designer** — File → Export, select the project. +**Gateway web UI** — Config → Projects → *project* → Export. + +Both produce the same zip. If your repo stores projects as unpacked files +(gateway-as-files), there is no zip in the repo — you must generate one. + +--- + +## 4. Verify the export + +```bash +unzip -l .zip # inventory +unzip -o .zip -d /tmp/verify-export # extract +``` + +Check: + +- **File count** matches the project on disk (minus anything the export legitimately drops). +- **Every view, style class and script module** you expect is present. +- **`README.md` presence** — confirm, do not assume. +- **No stray files**: `.resources/` (gateway-owned cache), `__pycache__`, editor backups. +- **No credentials** anywhere in the archive. + +Then do the real test: import it into a populated project and click through. + +--- + +## 5. Fill in the upload form + +Fields in the order the Exchange presents them. + +### Overview + +| Field | What to put | +|---|---| +| **Visibility** | `Public` unless it is genuinely private. | +| **Title** | Short, Title Case, says what it does. Usually matches the project title. | +| **Tagline** | One sentence. Must **not** repeat the title. | +| **Description** | What it does, how it is meant to be used, who benefits, relevant industries. | +| **Resource Type** | Closest match from the dropdown, plus a skill level reflecting install/use complexity. | +| **Background Image** | Optional colour/design for the resource card. | +| **Category** | **Up to 3.** Chosen from: Alarming, Analytics, Connectivity, Dashboard, Diagnostics, Edge Computing, Enterprise, HMI, IIOT/MQTT, MES, Maintenance, Mapping/GIS, Mobile, Monitoring, Reporting, SCADA, Scripting, Security, Simulation, Trending/Charting, Utility. | +| **Contact the Developer** | Optional. Your address is never shown; you receive the question plus the asker's address. | +| **Tags** | **Up to 10** search keywords. | +| **Images & Screenshots** | Optional but highly encouraged — shows what the title and description cannot. | + +### Package + +| Field | What to put | +|---|---| +| **Version** | `1.0.0` for a first release; later versions auto-increment. **Do not** put the version in the project name. | +| **Ignition Platform** | The **minimum** version required. The guide: *"If unsure, select the Ignition platform version in which the package was developed with."* Do not claim a floor you have not tested. | +| **Release Tagline** | Short summary of this version's changes. Required even for the first release. | +| **Release Notes** | Fuller explanation of what changed and how it affects users. | +| **Required Modules** | Any Inductive Automation / Cirrus Link / Sepasoft modules needed. | +| **Maker Edition** | Tick if it runs on Maker. Unavailable if you require modules Maker does not support. | +| **Other Requirements** | External or custom prerequisites, one per line. Blank if none. | +| **Package Files** | The zip, plus docs / tags / SQL as applicable. | +| **Custom Installation Instructions** | Step-by-step setup. Assume a beginner. If you shipped separate documentation, say so here and include the file. | + +The Exchange **auto-generates a readme** from your Description and Custom +Installation Instructions, so write both properly. + +### Verifying your minimum-version claim + +Spin up a throwaway gateway at the version you intend to declare and load the +project into it. Use a **copy** of the project files — an older gateway rewrites +`resource.json` metadata and leaves root-owned `.resources/` artifacts behind. + +A clean load proves resources parse. It does **not** prove screens render: a +component property added in a later version fails at render time, not load time. +Open the client and look. + +--- + +## 6. Worked example — Alarm Analysis Dashboard + +| Field | Value | +|---|---| +| Title | Alarm Analysis Dashboard | +| Tagline | ISA-18.2 alarm performance metrics straight from the alarm journal | +| Resource Type | Perspective View — Intermediate | +| Category | Alarming, Analytics, Dashboard | +| Tags | alarms, ISA-18.2, alarm journal, MTTA, MTTR, bad actors, alarm rate, KPI, dashboard, perspective | +| Version | 1.0.0 | +| Ignition Platform | 8.1.20 | +| Required Modules | Perspective | +| Maker Edition | Compatible | +| Other Requirements | An alarm journal profile with data | +| Package Files | `alarm-analysis.zip`, `README.md` | + +Needs no tag export and no database dump — it reads the journal through +`system.alarm.queryJournal` / `queryStatus` with no journal name, datasource or +tag dependency. That portability is what the guide's structure rules produce. + +--- + +## 7. After publishing + +- **Updates** are new package versions on the same resource, not new resources. + Keep the namespace stable so users' imports upgrade in place. +- **Never rename the namespace folders** between versions — that turns an upgrade + into a duplicate set of resources in every user's project. +- Re-run `exchange_lint.py` before every release. +- Non-English resources: the guide encourages shipping a language dropdown with + English alongside, to widen access. diff --git a/exchangeResources/README.md b/exchangeResources/README.md new file mode 100644 index 0000000..c1685d1 --- /dev/null +++ b/exchangeResources/README.md @@ -0,0 +1,156 @@ +# Ignition Exchange resources + +Getting an Ignition project ready to publish on the Ignition Exchange means +following Inductive Automation's naming and structure conventions — mainly so +that when someone imports your resource into a project they already have, nothing +of theirs gets overwritten. + +These files help you do that. + +| File | What it's for | +|---|---| +| **[`exchange_lint.py`](exchange_lint.py)** | Check your project against the conventions. Start here. | +| **[`PUBLISHING.md`](PUBLISHING.md)** | Package and upload it, once it passes. | +| [`Exchange+Resources+Style+Guide.md`](Exchange+Resources+Style+Guide.md) | The official guide, v1.1.0. Every rule traces back to it. | + +**The path: check your project → fix what it reports → follow `PUBLISHING.md`.** + +--- + +## Checking your project + +```bash +python3 exchange_lint.py /path/to/YourProject +``` + +Nothing to install — Python 3.8+ and the standard library. + +Point it at any of these: + +- your project folder +- a folder containing several projects (a gateway's `projects/` directory, an + unpacked gateway backup, your repo) +- a project export `.zip` +- nothing, to scan the current directory + +There's no config file. It works out your resource name from the namespace your +project already uses, and picks up tag exports and `.sql` dumps sitting alongside. + +--- + +## Reading the report + +``` +Ignition Exchange Style Guide v1.1.0 — conformance report + +project: my-dashboard resource: MyDashboard + + Resource Structure + FAIL E-NS-ROOT script-python/myutils: project library package + 'myutils' is not under exchange//; importing + would land scripts in the host project's namespace + + Perspective + warn E-COMP-NAME views/Exchange/MyDashboard/Home: component 'headerRow' + should be PascalCase + + summary: 1 failure(s), 12 warning(s); 21 of 38 rules exercised +``` + +Each finding gives you the file, the problem, and why it matters. + +### FAIL — fix these before publishing + +Either the guide says *must*, or the rule protects your resource from damaging +someone else's project on import: resource namespacing, session properties, +Vision client tags, and the project-name character set. + +### warn — your call + +These are naming conventions. The guide is explicit that they're *"encouraged, +however these conventions are not required"*, and that consistency matters more +than which convention you choose. Read them, decide, move on. Add `--strict` if +you'd rather treat them as failures. + +### "21 of 38 rules exercised" + +How many rules actually applied to your project. If you have no database, no tags +and no named queries, those rules never ran — so a pass on a small project isn't +mistaken for a pass on everything. + +**Exit codes:** `0` clean · `1` findings · `2` bad invocation. + +--- + +## What gets checked + +38 rules. Run `python3 exchange_lint.py --list-rules` for the full list with +descriptions. + +| Area | What it looks at | +|---|---| +| **Resource structure** | Everything namespaced under `Exchange//` or `exchange//` — the guide's one hard *must*, and what keeps your import from overwriting the host project | +| **Projects** | URL-friendly project name, human-friendly title, no version suffix | +| **Perspective** | Naming of views, components, properties, pages and style classes; where style classes live; message-handler namespacing | +| **Variable storage** | No session custom properties, no Vision client tags — both break a clean import | +| **Code** | Tab indentation, camelCase functions and variables, PascalCase classes, docstrings, comment style, project-library layout | +| **Loggers** | `exchange.resourceName.LoggerName` hierarchy | +| **Database** | `ex_` table prefix, snake_case, `id` primary key, `_id` foreign keys | +| **Tags · Vision · Named queries · WebDev · Translations** | Naming conventions for each | +| **Upload readiness** | Description filled in, installation documentation present | + +## What it can't check for you + +A clean report doesn't mean ready to publish. Still on you: + +- **What's written on screen.** It reads names and structure, never label text. A + visible string like *"Acme Internal — do not distribute"* passes silently. + Search your user-visible strings before you publish. +- **That references still resolve.** It doesn't confirm an embedded view path, + style class or script call points at something real. If you've just renamed + things to fit the conventions, verify the project still works. +- **That it runs.** Nothing is executed or rendered. Load it on a gateway and + open the screens. +- **Your export.** The documentation rule checks your working folder, not the + zip you upload. See [`PUBLISHING.md`](PUBLISHING.md). + +--- + +## Options + +| Flag | Effect | +|---|---| +| `--resource NAME` | Set your resource name, if the inferred one is wrong | +| `--strict` | Treat every warning as a failure | +| `--ignore E-PY-VAR,E-COMP-NAME` | Suppress specific rules | +| `-v` | Show every occurrence instead of collapsing per rule | +| `--max-per-rule N` | Change the collapse limit (default 8) | +| `--json` | Machine-readable output, for CI | +| `--tags` / `--sql` | Point at tag exports or SQL dumps explicitly | +| `--list-rules` | Print the full rule table and exit | + +--- + +## Findings that need context + +Three rules produce messages that can be confusing on first read. + +**`E-NS-ROOT` — why capital `Exchange/` sometimes and lowercase `exchange/` other +times?** That's the guide's convention, not a typo. Views, windows, templates, +named queries, reports, SFCs, transaction groups, alarm pipelines and tags go +under `Exchange/`. Style classes, the project library, WebDev sources and images +go under `exchange/`. Using the wrong root is a `FAIL`; using the right root with +the wrong capitalisation is just a warning (`E-NS-CASE`). + +**`E-STYLE-HIER` may flag a class you're sharing deliberately.** It decides where +a style class belongs by finding which views reference it — but it only sees +class names written as plain text. If you build one in a binding, like +`'exchange/my-resource/priority/' + level.lower()`, it can't see that usage and +may report a shared class as used by only one view. If that's your situation, the +warning is safe to ignore. + +**`E-PAGE-ROOT` isn't in the guide.** It's an added caution: mapping a page to +`/` means your resource takes over the root URL of whatever project it's imported +into. Worth knowing before you decide — the trade-off is that a resource with no +`/` page can't be launched from the gateway's Perspective project list, only from +its full page URL. diff --git a/exchangeResources/claude-skills/INSTALL.md b/exchangeResources/claude-skills/INSTALL.md new file mode 100644 index 0000000..91782b1 --- /dev/null +++ b/exchangeResources/claude-skills/INSTALL.md @@ -0,0 +1,94 @@ +# Installing these skills into the `ignition` plugin + +Three Claude Code skills that package the Exchange tooling in this folder so +other people get it automatically, without knowing it exists. + +They are staged here rather than written directly into `AI-Tools` because that +repo was read-only for this work. Nothing below has been applied there yet. + +## Where they go + +Into the **existing `ignition` plugin**, not a new one. That follows the +marketplace's own rule: + +> Plugins are split by **domain**, not by repo it came from. […] A fourth plugin +> with one skill in it is worse than putting that skill somewhere slightly +> imperfect: it's another thing to install and another manifest to maintain. + +Exchange publishing is Ignition-domain work, and `ignition` already exists. + +```bash +cp -r ignition-exchange-conformance \ + ignition-exchange-convert \ + ignition-exchange-publish \ + ~/git/AI-Tools/plugins/ignition/skills/ +``` + +Resulting layout: + +``` +plugins/ignition/skills/ + ignition-gateway/ + ignition-trial-reset/ + ignition-exchange-conformance/ SKILL.md exchange_lint.py test_exchange_lint.py + ignition-exchange-convert/ SKILL.md verify_refs.py parity.py + ignition-exchange-publish/ SKILL.md +``` + +## Then: bump the plugin version + +`plugins/ignition/.claude-plugin/plugin.json` — `0.1.0` → `0.2.0`, and widen the +description, which currently only mentions the gateway API and trial reset: + +```json +{ + "name": "ignition", + "description": "Ignition 8.3 gateway skills. HTTP API authentication and endpoint discovery, trial-expiry detection and reset on unlicensed gateways, and Ignition Exchange publishing — conformance checking, converting a project into Exchange shape, and packaging for upload.", + "author": { "name": "Brendan Peck", "email": "b.peck@prime-controls.com" }, + "version": "0.2.0" +} +``` + +`.claude-plugin/marketplace.json` carries a second copy of that description — +update it to match, or the marketplace listing will understate the plugin. + +## Then: the README rows + +Under `### ignition`, the skill table becomes: + +```markdown +| Skill | Loads when you're... | +| --- | --- | +| `ignition-gateway` | calling an Ignition 8.3 gateway's HTTP API (`/data/api/v1/...`) | +| `ignition-trial-reset` | debugging bad-quality tags, or resetting a trial gateway | +| `ignition-exchange-conformance` | checking whether a project is ready for the Ignition Exchange | +| `ignition-exchange-convert` | turning an existing project into a publishable Exchange resource | +| `ignition-exchange-publish` | packaging and uploading a resource to the Exchange | +``` + +Suggested paragraph to follow it: + +> The three `ignition-exchange-*` skills cover the publishing pipeline end to +> end. `-conformance` runs a 38-rule checker against the Exchange Resources Style +> Guide and, just as usefully, says what it cannot check. `-convert` carries the +> migration order and two scripts that catch what a conformance checker cannot +> see: dangling references after a mass rename, and features silently dropped. +> `-publish` is the upload form, field by field. + +## Verify after copying + +```bash +cd ~/git/AI-Tools +python3 plugins/ignition/skills/ignition-exchange-conformance/test_exchange_lint.py +python3 -c "import json;[json.load(open(p)) for p in ['.claude-plugin/marketplace.json','plugins/ignition/.claude-plugin/plugin.json']];print('manifests parse')" +/plugin marketplace add ~/git/AI-Tools # local path works while developing +``` + +## Keeping the copies in sync + +`exchange_lint.py` and `test_exchange_lint.py` now exist twice: in +`exchangeResources/` (the working copy, used by this repo) and inside the skill +(the distributable). `verify_refs.py` and `parity.py` live **only** in the skill. + +If you change a rule, update both — or make `exchangeResources/` a symlink to the +skill copy and keep one source of truth. diff --git a/exchangeResources/claude-skills/ignition-exchange-conformance/SKILL.md b/exchangeResources/claude-skills/ignition-exchange-conformance/SKILL.md new file mode 100644 index 0000000..111a9fe --- /dev/null +++ b/exchangeResources/claude-skills/ignition-exchange-conformance/SKILL.md @@ -0,0 +1,149 @@ +--- +name: ignition-exchange-conformance +description: Check whether an Ignition project follows Inductive Automation's Exchange Resources Style Guide before publishing it to the Ignition Exchange — resource namespacing, Perspective/Vision/Python naming, session-property and client-tag prohibitions, database and logger conventions. Use when asked whether a project is Exchange-ready, when reviewing a project intended for the Exchange, or before packaging one for upload. Bundles a stdlib-only checker (exchange_lint.py) with 38 rules traceable to guide sections. Not for converting a project into that shape (use ignition-exchange-convert) or for packaging and upload (use ignition-exchange-publish). +--- + +# Ignition Exchange conformance + +Tests an Ignition project against the **Exchange Resources Style Guide v1.1.0**. + +Run `exchange_lint.py` (next to this file) rather than checking by eye — the rules +are numerous, mechanical, and easy to half-apply. + +```bash +python3 skills/ignition-exchange-conformance/exchange_lint.py /path/to/Project +``` + +Stdlib only, Python 3.8+. Accepts a project folder, a folder containing several +projects (a gateway's `projects/` directory, an unpacked backup, a repo), or a +project export `.zip`. Infers the resource name from the namespace the project +already uses — no configuration. + +Exit codes: `0` clean · `1` findings · `2` bad invocation. + +## The one rule that is actually mandatory + +The guide states exactly one `must`: + +> Views, Styles, Scripts, etc. **must** all be contained within an appropriately +> named folder. This allows easy imports into existing projects, where those +> project resources won't overlap, interfere, or overwrite existing parts of a +> user's project. + +Everything else — every naming convention — the guide explicitly calls optional: + +> We encourage you to follow the naming conventions outlined here, however +> **these conventions are not required**. + +So `exchange_lint.py` reports **FAIL** only for namespacing, the storage +prohibitions, and the project-name character set. Naming is **warn**. Don't +report warnings as blockers, and don't contort a project to silence them — +a consistent house style is defensible and the guide says so. + +## Namespace roots — two spellings, both correct + +Per the guide's Project Browser screenshots: + +| Under `Exchange//` | Under `exchange//` | +|---|---| +| Views, Windows, Templates | Style classes | +| Named queries, Reports | Project library (scripts) | +| SFCs, Transaction groups | WebDev sources | +| Alarm pipelines, Tags | Images | + +Uppercase roots take Title Case or PascalCase names; lowercase roots take +kebab-case — **except the project library**, which is code-facing and uses +lowercase or camelCase (`exchange.resourceName.scriptName`), matching the +logger convention `exchange.resourceName.LoggerName`. + +## The storage prohibitions + +Both are FAILs because both break a clean import, and both are easy to miss: + +- **Perspective session custom properties.** Importing merges them into the host + project's session props. Use view `params`/`custom` plus, for genuinely + non-reactive cross-session state, `system.util.getGlobals()['exchange']['resourceName']`. +- **Vision client tags.** Same reasoning. + +## What the checker cannot tell you + +A clean report is not "ready to publish". Four gaps, all of which have bitten: + +1. **Content.** It reads names and structure, never label text. A visible string + like `"Acme Internal — do not distribute"` passes silently. Always sweep + user-visible strings separately: + ```bash + python3 - <<'PY' + import json, glob, re + bad = re.compile(r"internal|confidential|do not|contest|demo only|todo|fixme", re.I) + for f in glob.glob("/com.inductiveautomation.perspective/views/**/view.json", recursive=True): + v = json.load(open(f)) + def walk(n): + yield n + for c in (n.get("children") or []): yield from walk(c) + for n in walk(v.get("root") or {}): + t = (n.get("props") or {}).get("text") + if isinstance(t, str) and bad.search(t): print(f, repr(t)) + PY + ``` +2. **Referential integrity.** It does not check that embedded view paths, style + classes or script calls resolve. See [`ignition-exchange-convert`](../ignition-exchange-convert/SKILL.md). +3. **Runtime.** Nothing is executed or rendered. Load it on a gateway. +4. **The shipped package.** `E-UP-DOCS` checks the working directory, not the + export zip. See [`ignition-exchange-publish`](../ignition-exchange-publish/SKILL.md). + +## Findings that need interpretation + +**`E-STYLE-HIER` false positives.** It decides where a style class belongs by +finding which views reference it, reading only literal `props.style.classes` +strings. A class name built in a binding — +`'exchange/my-resource/priority/' + level.lower()` — is invisible, so a shared +class gets reported as single-use. Verify before acting; do not duplicate style +classes to silence it. + +**`E-PAGE-ROOT` is not in the guide.** It is an added caution: a page mapped to +`/` takes over the root URL of any project the resource is imported into. Real +trade-off — a resource with no `/` page cannot be launched from the gateway's +Perspective project list, only from its full page URL +(`/data/perspective/client//`). Flag the trade-off; let the user +decide. + +**`E-PY-VAR` volume.** A project written in idiomatic snake_case produces +hundreds of warnings, since the guide wants camelCase for variables and +functions (deliberately diverging from PEP-8; `UPPER_SNAKE` constants are +exempt). Offer `--ignore E-PY-VAR,E-PY-FUNC` rather than a mass rename, unless +the user wants full conformance. + +## Useful flags + +| Flag | Effect | +|---|---| +| `--strict` | Warnings become failures | +| `--ignore E-PY-VAR,E-COMP-NAME` | Suppress rules | +| `-v` | Every occurrence (default collapses at 8 per rule) | +| `--json` | Machine-readable, for CI | +| `--list-rules` | All 38 rules with descriptions | + +## Reporting results + +The summary reads `N of 38 rules exercised`. Quote it. A project with no +database, tags or named queries legitimately leaves those rules unexercised, and +a narrow pass should never be presented as a broad one. + +## Self-test + +`test_exchange_lint.py` builds a conforming and a non-conforming project in a +temp dir, asserts the checker is silent on the first and fires the expected rule +id on the second, and asserts every declared rule is reachable in the source. +Run it after changing any rule. + +```bash +python3 skills/ignition-exchange-conformance/test_exchange_lint.py +``` + +## Provenance + +Rules derived from Exchange Resources Style Guide v1.1.0. Checker exercised +against four real Perspective projects and validated on Ignition **8.1.20** and +**8.3.7** gateways (2026-09-15). Where a rule is an inference rather than guide +text it says so in its description — currently only `E-PAGE-ROOT`. diff --git a/exchangeResources/claude-skills/ignition-exchange-conformance/exchange_lint.py b/exchangeResources/claude-skills/ignition-exchange-conformance/exchange_lint.py new file mode 100755 index 0000000..d5daa60 --- /dev/null +++ b/exchangeResources/claude-skills/ignition-exchange-conformance/exchange_lint.py @@ -0,0 +1,1243 @@ +#!/usr/bin/env python3 +"""exchange_lint.py — conformance checker for the Ignition Exchange Resources Style Guide. + +Tests whether an Ignition project on disk (8.1 project folder layout) follows the +naming, structure, and storage conventions in "Exchange Resources Style Guide" +v1.1.0, and whether it is ready to upload to the Exchange. + +Every finding carries a rule id (E-...) so it can be traced back to a section of +the guide; run with --list-rules to print the full table. + +Severity follows the guide's own language: + FAIL the guide says "must", or the rule protects importability into someone + else's project (resource namespacing, session/client-tag storage). + warn a naming convention -- the guide "encourages" these but states they are + not required. --strict promotes every warning to a failure. + +Targets: a project folder, any folder containing projects (a gateway's +projects/ directory, an unpacked gateway backup, a source repo), or a +project-export .zip. The resource name is inferred from the namespace the +project actually uses, so no per-project configuration is needed. + +Usage: + python3 exchange_lint.py # scan the current directory + python3 exchange_lint.py /path/to/MyProject + python3 exchange_lint.py MyResource.zip --strict + python3 exchange_lint.py /gateway/projects # every project found + python3 exchange_lint.py --list-rules + +Exit status: 0 clean, 1 findings, 2 bad invocation. +Stdlib only; no third-party dependencies. +""" +import argparse +import ast +import io +import json +import os +import re +import sys +import tokenize + +PERSPECTIVE = "com.inductiveautomation.perspective" +VISION = "com.inductiveautomation.vision" + +# ---------------------------------------------------------------- rule table + +RULES = [ + # id, section, description + ("E-PROJ-NAME", "Projects", "project name lowercase, dashes, URL-friendly, no version suffix"), + ("E-PROJ-TITLE", "Projects", "project title human-friendly Title Case"), + ("E-PROJ-DESC", "Upload", "project description filled in (feeds the Exchange overview)"), + ("E-NS-ROOT", "Resource Structure", "every resource under Exchange// or exchange//"), + ("E-NS-CASE", "Resource Structure", "correct case of the namespace root for the resource type"), + ("E-NS-NAME", "Resource Structure", "second path segment is the resource name"), + ("E-VIEW-NAME", "Perspective", "view names Title Case or PascalCase"), + ("E-COMP-NAME", "Perspective", "component names PascalCase"), + ("E-PROP-NAME", "Perspective", "custom/param property names camelCase"), + ("E-PAGE-NAME", "Perspective", "page names lowercase with dashes"), + ("E-PAGE-ROOT", "Perspective", "page '/' claims the root URL of any importing project"), + ("E-STYLE-NAME", "Perspective", "style class names lowercase with dashes"), + ("E-STYLE-HIER", "Perspective", "style class placement mirrors the views that use it"), + ("E-MSG-NAME", "Perspective", "message handlers namespaced exchange.resourceName.handlerName"), + ("E-STORE-SESS", "Variable Storage", "no Perspective session custom properties (blocks clean import)"), + ("E-STORE-CTAG", "Variable Storage", "no Vision client tags (blocks clean import)"), + ("E-STORE-GLOB", "Variable Storage", "getGlobals() keyed ['exchange']['resourceName']"), + ("E-TAG-NAME", "Tags", "tag folders/names PascalCase or Title Case under Exchange/"), + ("E-VIS-NAME", "Vision", "window/template names Title Case or PascalCase"), + ("E-PY-TABS", "Code", "Python indented with tabs, not spaces"), + ("E-PY-LIB", "Code", "project library packages lowercase, single words"), + ("E-PY-FUNC", "Code", "function names camelCase"), + ("E-PY-CLASS", "Code", "class names PascalCase"), + ("E-PY-VAR", "Code", "variable names camelCase"), + ("E-PY-DOC", "Code", "public functions/classes carry a docstring"), + ("E-PY-DOCFMT", "Code", "docstring summary ends with . ? or !"), + ("E-PY-COMMENT", "Code", "comments start with '# ' (hash then space)"), + ("E-PY-PARSE", "Code", "source parses (checks below it are skipped otherwise)"), + ("E-LOG-NAME", "Loggers", "logger names exchange.resourceName[...].PascalCaseName"), + ("E-DB-TABLE", "Database", "table names ex__snake_case"), + ("E-DB-COL", "Database", "column names snake_case"), + ("E-DB-ID", "Database", "every table has an 'id' primary key"), + ("E-DB-FK", "Database", "foreign key columns named _id"), + ("E-NQ-NAME", "Named Queries", "named query names Title Case or PascalCase"), + ("E-NQ-PARAM", "Named Queries", "named query parameters camelCase"), + ("E-WEB-NAME", "WebDev", "WebDev sources lowercase with dashes"), + ("E-I18N-KEY", "Translation", "translation keys 'word' or '_translation_phrase'"), + ("E-UP-DOCS", "Upload", "installation/README documentation present in the resource"), +] +RULE_SECTIONS = {r[0]: r[1] for r in RULES} +VALID_RULES = set(RULE_SECTIONS) + +# Resource types and the case their namespace root uses, per the guide's +# Project Browser screenshots (uppercase Exchange/ for designer resources that +# are themselves capitalised; lowercase exchange/ for URL- and code-facing ones). +UPPER = "Exchange" +LOWER = "exchange" + +# ------------------------------------------------------------ name predicates + +RX_PASCAL = re.compile(r"^[A-Z][A-Za-z0-9]*$") +RX_CAMEL = re.compile(r"^[a-z][A-Za-z0-9]*$") +RX_KEBAB = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +RX_SNAKE = re.compile(r"^[a-z][a-z0-9_]*$") +RX_CONST = re.compile(r"^[A-Z][A-Z0-9_]*$") +RX_PROJECT = re.compile(r"^[a-z0-9]+([-_][a-z0-9]+)*$") +RX_VERSION_SUFFIX = re.compile(r"[-_. ]v?\d+([._]\d+)*$", re.IGNORECASE) +# Title Case tolerates lowercase joining words in non-initial position. +SMALL_WORDS = {"a", "an", "and", "as", "at", "by", "for", "in", "of", "on", + "or", "the", "to", "vs", "with"} + + +def is_pascal(s): + return bool(RX_PASCAL.match(s)) + + +def is_camel(s): + return bool(RX_CAMEL.match(s)) + + +def is_kebab(s): + return bool(RX_KEBAB.match(s)) + + +def is_title(s): + words = s.split(" ") + if len(words) < 2 or any(not w for w in words): + return False + for i, w in enumerate(words): + if RX_PASCAL.match(w): + continue + if i > 0 and w.lower() in SMALL_WORDS: + continue + return False + return True + + +def is_title_or_pascal(s): + """The guide lets top level resources be either Title Case or PascalCase.""" + return is_pascal(s) or is_title(s) + + +def norm(s): + """Fold a name to bare lowercase alphanumerics for cross-style comparison.""" + return re.sub(r"[^a-z0-9]", "", (s or "").lower()) + + +def pascalize(s): + return "".join(p[:1].upper() + p[1:] for p in re.split(r"[-_ ]+", s or "") if p) + + +def kebabize(s): + s = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", s or "") + return re.sub(r"[-_ ]+", "-", s).lower() + + +# ------------------------------------------------------------------- findings + +class Report(object): + def __init__(self, ignore=None, strict=False): + self.findings = [] # (severity, rule, where, message) + self.ignore = ignore or set() + self.strict = strict + self.checked = set() + + def _add(self, sev, rule, where, msg): + self.checked.add(rule) + if rule in self.ignore: + return + if sev == "warn" and self.strict: + sev = "FAIL" + self.findings.append((sev, rule, where, msg)) + + def fail(self, rule, where, msg): + self._add("FAIL", rule, where, msg) + + def warn(self, rule, where, msg): + self._add("warn", rule, where, msg) + + def touch(self, *rules): + """Record that a rule ran even though it produced no finding.""" + self.checked.update(rules) + + @property + def fails(self): + return [f for f in self.findings if f[0] == "FAIL"] + + @property + def warns(self): + return [f for f in self.findings if f[0] == "warn"] + + +# --------------------------------------------------------------- file helpers + +def load_json(rep, path, where): + try: + with open(path) as f: + return json.load(f) + except Exception as e: + rep.fail("E-PY-PARSE", where, "invalid JSON: %s" % e) + return None + + +def read_text(path): + with open(path, "r", encoding="utf-8", errors="replace") as f: + return f.read() + + +def find_payload_dirs(root, payload): + """Yield path-segment tuples for every directory under root holding payload.""" + out = [] + if not os.path.isdir(root): + return out + for dirpath, _dirs, files in os.walk(root): + if payload in files: + rel = os.path.relpath(dirpath, root) + if rel != ".": + out.append(tuple(rel.split(os.sep))) + return sorted(out) + + +def find_resource_dirs(root): + """Yield path-segment tuples for every leaf directory holding a resource.json.""" + out = [] + if not os.path.isdir(root): + return out + for dirpath, _dirs, files in os.walk(root): + if "resource.json" in files: + rel = os.path.relpath(dirpath, root) + if rel != ".": + out.append(tuple(rel.split(os.sep))) + return sorted(out) + + +def walk_components(node, visit, path="root"): + """Depth-first over a Perspective view component tree; visit(node, path).""" + if not isinstance(node, dict): + return + visit(node, path) + for i, child in enumerate(node.get("children") or []): + name = "?" + if isinstance(child, dict): + name = (child.get("meta") or {}).get("name", "?") + walk_components(child, visit, "%s/%s[%d]" % (path, name, i)) + + +# ------------------------------------------------------------ namespace check + +def check_namespace(rep, kind, parts, where, want_case, resource, name_style): + """Check //... on one resource path. + + parts path segments below the resource-type root + want_case UPPER or LOWER -- the expected spelling of the first segment + name_style "title-pascal" or "kebab" -- expected style of the resource segment + Returns the segments whose names still need style checking: those below + the namespace when it is correct, or below the wrong root when it is not. + """ + if len(parts) < 2: + rep.fail("E-NS-ROOT", where, + "%s is not inside a %s// folder (imports will collide " + "with the target project)" % (kind, want_case)) + return list(parts) + root, name = parts[0], parts[1] + if norm(root) != "exchange": + rep.fail("E-NS-ROOT", where, + "%s namespace root is %r, expected %r" % (kind, root, want_case)) + # Keep checking the names below the (wrong) root so a project that has + # not been namespaced yet still gets its naming findings. + return list(parts[1:]) + rep.touch("E-NS-ROOT") + if root != want_case: + rep.warn("E-NS-CASE", where, + "namespace root %r should be %r for %s" % (root, want_case, kind)) + else: + rep.touch("E-NS-CASE") + if resource and norm(name) != norm(resource): + rep.warn("E-NS-NAME", where, + "resource folder %r does not match resource name %r" % (name, resource)) + else: + rep.touch("E-NS-NAME") + if name_style == "kebab" and not is_kebab(name): + rep.warn("E-NS-NAME", where, + "resource folder %r should be lowercase-with-dashes" % name) + elif name_style == "title-pascal" and not is_title_or_pascal(name): + rep.warn("E-NS-NAME", where, + "resource folder %r should be Title Case or PascalCase" % name) + elif name_style == "camel" and not is_camel(name): + rep.warn("E-NS-NAME", where, + "resource folder %r should be lowercase or camelCase " + "(exchange.resourceName.scriptName)" % name) + return parts[2:] + + +# ------------------------------------------------------------------- sections + +def check_project(rep, proj_dir, proj_name): + """Projects: name, title, description.""" + where = "project.json" + if not RX_PROJECT.match(proj_name): + rep.fail("E-PROJ-NAME", where, + "project name %r must be lowercase letters, numbers, hyphens and " + "underscores only" % proj_name) + elif RX_VERSION_SUFFIX.search(proj_name): + rep.warn("E-PROJ-NAME", where, + "project name %r looks version-suffixed; the Exchange manages " + "versions on upload" % proj_name) + else: + rep.touch("E-PROJ-NAME") + + pj = os.path.join(proj_dir, "project.json") + if not os.path.exists(pj): + rep.warn("E-PROJ-TITLE", where, "no project.json found") + return + meta = load_json(rep, pj, where) or {} + title = (meta.get("title") or "").strip() + if not title: + rep.warn("E-PROJ-TITLE", where, "project title is empty") + elif not (is_title(title) or is_pascal(title)): + rep.warn("E-PROJ-TITLE", where, + "project title %r should be human-friendly Title Case" % title) + else: + rep.touch("E-PROJ-TITLE") + if not (meta.get("description") or "").strip(): + rep.warn("E-PROJ-DESC", where, + "project description is empty; the Exchange builds the resource " + "readme from it") + else: + rep.touch("E-PROJ-DESC") + + +def check_perspective(rep, proj_dir, resource): + """Perspective: views, components, properties, pages, style classes.""" + views_root = os.path.join(proj_dir, PERSPECTIVE, "views") + styles_root = os.path.join(proj_dir, PERSPECTIVE, "style-classes") + + view_paths = find_payload_dirs(views_root, "view.json") + style_paths = find_payload_dirs(styles_root, "style.json") + + # ---- views ----------------------------------------------------------- + for parts in view_paths: + where = "views/" + "/".join(parts) + tail = check_namespace(rep, "view", parts, where, UPPER, resource, "title-pascal") + for seg in (tail if tail is not None else []): + if not is_title_or_pascal(seg): + rep.warn("E-VIEW-NAME", where, + "view path segment %r should be Title Case or PascalCase" % seg) + else: + rep.touch("E-VIEW-NAME") + check_view_file(rep, os.path.join(views_root, *parts), where, resource) + + # ---- style classes --------------------------------------------------- + for parts in style_paths: + where = "style-classes/" + "/".join(parts) + tail = check_namespace(rep, "style class", parts, where, LOWER, resource, "kebab") + for seg in (tail if tail is not None else []): + if not is_kebab(seg): + rep.warn("E-STYLE-NAME", where, + "style class segment %r should be lowercase-with-dashes" % seg) + else: + rep.touch("E-STYLE-NAME") + + check_style_hierarchy(rep, views_root, view_paths, style_paths) + + # ---- pages ----------------------------------------------------------- + pc_path = os.path.join(proj_dir, PERSPECTIVE, "page-config", "config.json") + if os.path.exists(pc_path): + pc = load_json(rep, pc_path, "page-config/config.json") or {} + for url in (pc.get("pages") or {}): + where = "page-config/config.json" + if url == "/": + rep.warn("E-PAGE-ROOT", where, + "page '/' takes over the root URL of any project this " + "resource is imported into") + continue + rep.touch("E-PAGE-ROOT") + for seg in [s for s in url.split("/") if s]: + if seg.startswith(":"): # path parameter + continue + if not is_kebab(seg): + rep.warn("E-PAGE-NAME", where, + "page url segment %r should be lowercase-with-dashes" % seg) + else: + rep.touch("E-PAGE-NAME") + + # ---- session properties --------------------------------------------- + sp_path = os.path.join(proj_dir, PERSPECTIVE, "session-props", "props.json") + if os.path.exists(sp_path): + sp = load_json(rep, sp_path, "session-props/props.json") or {} + custom = sp.get("custom") or {} + leaves = _leaf_paths(custom) + if leaves: + rep.fail("E-STORE-SESS", "session-props/props.json", + "%d custom session propert%s (%s%s) -- the guide directs " + "resources to use view properties and system.util.getGlobals() " + "instead, so importing does not merge into the host project's " + "session props" + % (len(leaves), "y" if len(leaves) == 1 else "ies", + ", ".join(leaves[:3]), ", ..." if len(leaves) > 3 else "")) + else: + rep.touch("E-STORE-SESS") + + +def _leaf_paths(node, prefix=""): + """Flatten a nested property dict to dotted leaf paths.""" + out = [] + if isinstance(node, dict) and node: + for k in sorted(node): + out.extend(_leaf_paths(node[k], "%s.%s" % (prefix, k) if prefix else k)) + elif prefix: + out.append(prefix) + return out + + +def check_view_file(rep, view_dir, where, resource): + """Component names, custom/param property names, message handlers, globals.""" + vfile = os.path.join(view_dir, "view.json") + view = load_json(rep, vfile, where) + if view is None: + return + raw = read_text(vfile) + + for group in ("params", "custom"): + for key in (view.get(group) or {}): + if not is_camel(key): + rep.warn("E-PROP-NAME", where, + "view %s property %r should be camelCase" % (group, key)) + else: + rep.touch("E-PROP-NAME") + for key in (view.get("propConfig") or {}): + if key.startswith(("params.", "custom.")): + leaf = key.split(".", 1)[1].split(".")[0].split("[")[0] + if leaf and not is_camel(leaf): + rep.warn("E-PROP-NAME", where, + "view property %r should be camelCase" % key) + else: + rep.touch("E-PROP-NAME") + + def visit(node, cpath): + name = (node.get("meta") or {}).get("name") + if cpath == "root" or not name: + return + if not is_pascal(name): + rep.warn("E-COMP-NAME", where, "component %r should be PascalCase" % name) + else: + rep.touch("E-COMP-NAME") + for key in (node.get("custom") or {}): + if not is_camel(key): + rep.warn("E-PROP-NAME", where, + "custom property %r on %r should be camelCase" % (key, name)) + else: + rep.touch("E-PROP-NAME") + walk_components(view.get("root") or {}, visit) + + check_message_names(rep, raw, where, resource) + check_globals(rep, raw, where, resource) + + +def check_message_names(rep, text, where, resource): + """Message handler / sendMessage types namespaced to the resource.""" + seen = set() + for rx in (r'"messageType"\s*:\s*"([^"]+)"', + r'sendMessage\(\s*[\'"]([^\'"]+)[\'"]', + r'messageType\s*=\s*[\'"]([^\'"]+)[\'"]'): + for m in re.finditer(rx, text): + seen.add(m.group(1)) + for mt in sorted(seen): + parts = mt.split(".") + if len(parts) >= 3 and parts[0] == "exchange" and norm(parts[1]) == norm(resource): + rep.touch("E-MSG-NAME") + continue + rep.warn("E-MSG-NAME", where, + "message type %r should be exchange.%s.handlerName to avoid " + "collisions" % (mt, resource[:1].lower() + resource[1:])) + + +def check_globals(rep, text, where, resource): + """getGlobals() must be keyed ['exchange']['resourceName'].""" + for m in re.finditer(r"getGlobals\(\)\s*((?:\[\s*[\'\"][^\'\"]+[\'\"]\s*\])*)", text): + keys = re.findall(r"[\'\"]([^\'\"]+)[\'\"]", m.group(1)) + if len(keys) >= 2 and keys[0] == "exchange" and norm(keys[1]) == norm(resource): + rep.touch("E-STORE-GLOB") + else: + rep.warn("E-STORE-GLOB", where, + "getGlobals()%s should be keyed ['exchange']['%s']" + % ("".join("[%r]" % k for k in keys) or "[...]", + resource[:1].lower() + resource[1:])) + + +def check_style_hierarchy(rep, views_root, view_paths, style_paths): + """A class used by exactly one view belongs in a folder named for that view.""" + if not style_paths: + return + known = {"/".join(p) for p in style_paths} + users = {} + for parts in view_paths: + vfile = os.path.join(views_root, *parts, "view.json") + if not os.path.exists(vfile): + continue + view = load_json(rep, vfile, "views/" + "/".join(parts)) + if view is None: + continue + view_name = parts[-1] + + def visit(node, _cpath): + classes = ((node.get("props") or {}).get("style") or {}).get("classes") + if isinstance(classes, str): + for cls in classes.split(): + if cls in known: + users.setdefault(cls, set()).add(view_name) + walk_components(view.get("root") or {}, visit) + + for cls, vnames in sorted(users.items()): + segs = cls.split("/") + view_segs = {norm(s) for s in segs} + if len(vnames) == 1: + vn = next(iter(vnames)) + if norm(vn) not in view_segs: + rep.warn("E-STYLE-HIER", "style-classes/" + cls, + "used only by view %r; place it in a folder named for that " + "view" % vn) + else: + rep.touch("E-STYLE-HIER") + else: + shared = view_segs & {norm(v) for v in vnames} + if shared: + rep.warn("E-STYLE-HIER", "style-classes/" + cls, + "used by %d views (%s) but nested under a view-specific " + "folder; move it to the topmost folder covering them all" + % (len(vnames), ", ".join(sorted(vnames)[:3]))) + else: + rep.touch("E-STYLE-HIER") + + +def check_vision(rep, proj_dir, resource): + """Vision: windows, templates, and the client-tag prohibition.""" + vis = os.path.join(proj_dir, VISION) + if not os.path.isdir(vis): + return + ctags = os.path.join(vis, "client-tags") + if os.path.isdir(ctags) and os.listdir(ctags): + rep.fail("E-STORE-CTAG", "%s/client-tags" % VISION, + "Vision client tags present -- the guide directs resources away " + "from client tags so they import cleanly into another project") + else: + rep.touch("E-STORE-CTAG") + + for sub, kind in (("windows", "window"), ("templates", "template")): + root = os.path.join(vis, sub) + for parts in find_resource_dirs(root): + where = "%s/%s/%s" % (VISION, sub, "/".join(parts)) + tail = check_namespace(rep, kind, parts, where, UPPER, resource, "title-pascal") + for seg in (tail if tail is not None else []): + if not is_title_or_pascal(seg): + rep.warn("E-VIS-NAME", where, + "%s name %r should be Title Case or PascalCase" % (kind, seg)) + else: + rep.touch("E-VIS-NAME") + + +def check_scripts(rep, proj_dir, resource): + """Code: library layout, indentation, names, docstrings, comments, loggers.""" + sp_root = os.path.join(proj_dir, "ignition", "script-python") + if not os.path.isdir(sp_root): + return + for dirpath, _dirs, files in os.walk(sp_root): + if "code.py" not in files: + continue + parts = tuple(os.path.relpath(dirpath, sp_root).split(os.sep)) + where = "script-python/" + "/".join(parts) + if norm(parts[0]) == "exchange": + tail = check_namespace(rep, "script package", parts, where, LOWER, + resource, "camel") + tail = tail if tail is not None else [] + else: + rep.fail("E-NS-ROOT", where, + "project library package %r is not under exchange//; " + "importing would land scripts in the host project's namespace" + % parts[0]) + tail = list(parts[1:]) + for seg in tail: + if not is_camel(seg): + rep.warn("E-PY-LIB", where, + "library package/script %r should be lowercase, a single " + "word where possible, camelCase otherwise" % seg) + else: + rep.touch("E-PY-LIB") + check_python_file(rep, os.path.join(dirpath, "code.py"), where + "/code.py", + resource) + + +def check_python_file(rep, path, where, resource): + src = read_text(path) + check_py_indentation(rep, src, where) + check_py_comments(rep, src, where) + check_globals(rep, src, where, resource) + check_message_names(rep, src, where, resource) + check_loggers(rep, src, where, resource) + try: + tree = ast.parse(src) + except SyntaxError as e: + rep.warn("E-PY-PARSE", where, + "could not parse (line %s): %s -- name and docstring checks skipped" + % (e.lineno, e.msg)) + return + rep.touch("E-PY-PARSE") + check_py_names(rep, tree, where) + + +def check_py_indentation(rep, src, where): + """Tabs, not spaces. Uses the tokenizer so continuation lines are not counted.""" + space_lines, tab_lines = [], [] + try: + for tok in tokenize.generate_tokens(io.StringIO(src).readline): + if tok.type == tokenize.INDENT: + (tab_lines if "\t" in tok.string else space_lines).append(tok.start[0]) + except (tokenize.TokenError, IndentationError, SyntaxError): + for i, line in enumerate(src.splitlines(), 1): + if line.startswith("\t"): + tab_lines.append(i) + elif line.startswith(" "): + space_lines.append(i) + if space_lines: + preview = ", ".join(str(n) for n in space_lines[:5]) + rep.warn("E-PY-TABS", where, + "%d space-indented block%s (line%s %s%s); Ignition's editor inserts " + "tabs" % (len(space_lines), "" if len(space_lines) == 1 else "s", + "" if len(space_lines) == 1 else "s", preview, + ", ..." if len(space_lines) > 5 else "")) + elif tab_lines: + rep.touch("E-PY-TABS") + + +def check_py_comments(rep, src, where): + """Comments start with '#' then a space.""" + bad = [] + try: + for tok in tokenize.generate_tokens(io.StringIO(src).readline): + if tok.type != tokenize.COMMENT: + continue + body = tok.string + if body in ("#", "#!"): + continue + if body.startswith("#!") and tok.start[0] == 1: + continue + if re.match(r"^#[-=#*]{2,}", body): # separator banners + continue + if not body.startswith("# "): + bad.append(tok.start[0]) + except (tokenize.TokenError, IndentationError, SyntaxError): + return + if bad: + rep.warn("E-PY-COMMENT", where, + "%d comment%s missing the space after '#' (line%s %s%s)" + % (len(bad), "" if len(bad) == 1 else "s", + "" if len(bad) == 1 else "s", + ", ".join(str(n) for n in bad[:5]), + ", ..." if len(bad) > 5 else "")) + else: + rep.touch("E-PY-COMMENT") + + +def check_py_names(rep, tree, where): + """Function/class/variable names and docstrings.""" + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + if not is_pascal(node.name.lstrip("_")): + rep.warn("E-PY-CLASS", where, + "class %r (line %d) should be PascalCase" % (node.name, node.lineno)) + else: + rep.touch("E-PY-CLASS") + check_docstring(rep, node, where, "class") + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + bare = node.name.strip("_") + dunder = node.name.startswith("__") and node.name.endswith("__") + if not dunder and bare and not is_camel(bare): + rep.warn("E-PY-FUNC", where, + "function %r (line %d) should be camelCase (the guide " + "diverges from PEP-8 here)" % (node.name, node.lineno)) + else: + rep.touch("E-PY-FUNC") + if not dunder: + check_docstring(rep, node, where, "function") + for arg in _all_args(node): + _check_var(rep, arg.arg, arg.lineno, where, "argument") + elif isinstance(node, ast.Assign): + for tgt in node.targets: + for name, lineno in _assigned_names(tgt): + _check_var(rep, name, lineno, where, "variable") + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + _check_var(rep, node.target.id, node.lineno, where, "variable") + + +def _all_args(node): + a = node.args + out = list(getattr(a, "posonlyargs", [])) + list(a.args) + list(a.kwonlyargs) + if a.vararg: + out.append(a.vararg) + if a.kwarg: + out.append(a.kwarg) + return out + + +def _assigned_names(tgt): + if isinstance(tgt, ast.Name): + yield tgt.id, tgt.lineno + elif isinstance(tgt, (ast.Tuple, ast.List)): + for el in tgt.elts: + for pair in _assigned_names(el): + yield pair + + +def _check_var(rep, name, lineno, where, kind): + bare = name.strip("_") + if not bare or bare == "self" or bare == "cls": + return + # Module constants fall back to PEP-8, which the guide names as the default + # for anything it does not cover. + if RX_CONST.match(bare): + rep.touch("E-PY-VAR") + return + if is_camel(bare): + rep.touch("E-PY-VAR") + return + rep.warn("E-PY-VAR", where, + "%s %r (line %d) should be camelCase" % (kind, name, lineno)) + + +def check_docstring(rep, node, where, kind): + doc = ast.get_docstring(node, clean=False) + if node.name.startswith("_"): + return + if not doc or not doc.strip(): + rep.warn("E-PY-DOC", where, + "%s %r (line %d) has no docstring; Ignition 8.1.32+ surfaces these " + "in autocomplete" % (kind, node.name, node.lineno)) + return + rep.touch("E-PY-DOC") + summary = doc.strip().splitlines()[0].strip() + if summary and summary[-1] not in ".?!": + rep.warn("E-PY-DOCFMT", where, + "%s %r (line %d) docstring summary should end with . ? or !" + % (kind, node.name, node.lineno)) + else: + rep.touch("E-PY-DOCFMT") + + +def check_loggers(rep, src, where, resource): + """exchange.resourceName[.path].PascalCaseName""" + want = resource[:1].lower() + resource[1:] + for m in re.finditer(r"getLogger\(\s*[\'\"]([^\'\"]+)[\'\"]", src): + name = m.group(1) + parts = name.split(".") + ok = (len(parts) >= 3 and parts[0] == "exchange" + and norm(parts[1]) == norm(resource) + and all(is_camel(p) for p in parts[2:-1]) + and is_pascal(parts[-1])) + if ok: + rep.touch("E-LOG-NAME") + else: + rep.warn("E-LOG-NAME", where, + "logger %r should be exchange.%s[.path].LoggerName with a " + "PascalCase final element" % (name, want)) + + +def check_named_queries(rep, proj_dir, resource): + root = os.path.join(proj_dir, "ignition", "named-query") + for parts in find_resource_dirs(root): + where = "named-query/" + "/".join(parts) + tail = check_namespace(rep, "named query", parts, where, UPPER, resource, + "title-pascal") + for seg in (tail if tail is not None else []): + if not is_title_or_pascal(seg): + rep.warn("E-NQ-NAME", where, + "named query %r should be Title Case or PascalCase" % seg) + else: + rep.touch("E-NQ-NAME") + qdir = os.path.join(root, *parts) + for fn in sorted(os.listdir(qdir)): + if not fn.endswith(".json") or fn == "resource.json": + continue + data = load_json(rep, os.path.join(qdir, fn), where) + for p in ((data or {}).get("parameters") or []): + pname = p.get("name") if isinstance(p, dict) else p + if isinstance(pname, str) and not is_camel(pname): + rep.warn("E-NQ-PARAM", where, + "query parameter %r should be camelCase" % pname) + elif isinstance(pname, str): + rep.touch("E-NQ-PARAM") + + +def check_other_resources(rep, proj_dir, resource): + """Alarm pipelines, SFCs, transaction groups, reports, WebDev, translations.""" + ign = os.path.join(proj_dir, "ignition") + upper_kinds = { + "alarm-pipeline": "alarm pipeline", "alarm-pipelines": "alarm pipeline", + "sfc": "SFC", "sfcs": "SFC", + "transaction-group": "transaction group", "transaction-groups": "transaction group", + "report": "report", "reports": "report", + } + if os.path.isdir(ign): + for entry in sorted(os.listdir(ign)): + kind = upper_kinds.get(entry) + if not kind: + continue + for parts in find_resource_dirs(os.path.join(ign, entry)): + where = "%s/%s" % (entry, "/".join(parts)) + tail = check_namespace(rep, kind, parts, where, UPPER, resource, + "title-pascal") + for seg in (tail if tail is not None else []): + if not is_title_or_pascal(seg): + rep.warn("E-VIEW-NAME", where, + "%s name %r should be Title Case or PascalCase" + % (kind, seg)) + + for webroot in (os.path.join(proj_dir, "com.inductiveautomation.webdev"), + os.path.join(ign, "webdev")): + if not os.path.isdir(webroot): + continue + base = os.path.join(webroot, "resources") + base = base if os.path.isdir(base) else webroot + for parts in find_resource_dirs(base): + where = "webdev/" + "/".join(parts) + tail = check_namespace(rep, "WebDev source", parts, where, LOWER, + resource, "kebab") + for seg in (tail if tail is not None else []): + stem = seg.rsplit(".", 1)[0] + if not is_kebab(stem): + rep.warn("E-WEB-NAME", where, + "WebDev source %r should be lowercase-with-dashes" % seg) + else: + rep.touch("E-WEB-NAME") + + check_translations(rep, proj_dir) + + +def check_translations(rep, proj_dir): + for dirpath, _dirs, files in os.walk(proj_dir): + for fn in files: + if "locale" not in fn and "translation" not in fn.lower(): + continue + if not fn.endswith(".json"): + continue + where = os.path.relpath(os.path.join(dirpath, fn), proj_dir) + data = load_json(rep, os.path.join(dirpath, fn), where) + keys = list(data) if isinstance(data, dict) else [] + for key in keys: + if not isinstance(key, str): + continue + ok = (re.match(r"^[A-Za-z0-9]+$", key) + or re.match(r"^_[a-z0-9]+(_[a-z0-9]+)*$", key)) + if ok: + rep.touch("E-I18N-KEY") + else: + rep.warn("E-I18N-KEY", where, + "translation key %r should be a single word, or " + "_underscore_separated_phrase" % key) + + +def check_tags(rep, tag_files, resource): + """Tag export JSON: Exchange//... with PascalCase or Title Case names.""" + for tf in tag_files: + where = os.path.basename(tf) + data = load_json(rep, tf, where) + if data is None: + continue + + def walk(node, trail): + if not isinstance(node, dict): + return + name = node.get("name") + here = trail + [name] if name else trail + if name: + if not is_title_or_pascal(name): + rep.warn("E-TAG-NAME", where, + "tag/folder %r should be PascalCase or Title Case" + % "/".join(here)) + else: + rep.touch("E-TAG-NAME") + for child in (node.get("tags") or []): + walk(child, here) + + roots = data.get("tags") or [] + top = [t.get("name") for t in roots if isinstance(t, dict)] + if not any(norm(n) == "exchange" for n in top if n): + rep.fail("E-NS-ROOT", where, + "tag export root is %s; tags must live under Exchange/%s/" + % (top or "(empty)", resource)) + for t in roots: + walk(t, []) + + +def check_database(rep, sql_files, resource): + """CREATE TABLE statements: ex_ prefix, snake_case, id PK, *_id foreign keys.""" + abbrev_hint = "".join(w[0] for w in re.findall(r"[A-Z][a-z]*", resource)).lower() + for sf in sql_files: + where = os.path.basename(sf) + sql = read_text(sf) + for m in re.finditer( + r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"\[]?([A-Za-z0-9_.]+)[`\"\]]?\s*\((.*?)\)\s*;", + sql, re.IGNORECASE | re.DOTALL): + table = m.group(1).split(".")[-1] + body = m.group(2) + if not RX_SNAKE.match(table): + rep.warn("E-DB-TABLE", where, + "table %r should be snake_case" % table) + elif not table.startswith("ex_"): + rep.warn("E-DB-TABLE", where, + "table %r should start with 'ex_' (e.g. ex_%s_%s) to avoid " + "collisions in an existing schema" + % (table, abbrev_hint or "abbrev", table)) + else: + rep.touch("E-DB-TABLE") + + cols = [] + for line in body.splitlines(): + line = line.strip().rstrip(",") + cm = re.match(r"^[`\"\[]?([A-Za-z0-9_]+)[`\"\]]?\s+[A-Za-z]", line) + if not cm: + continue + kw = cm.group(1).upper() + if kw in ("PRIMARY", "FOREIGN", "UNIQUE", "KEY", "INDEX", "CONSTRAINT", "CHECK"): + continue + cols.append(cm.group(1)) + for col in cols: + if not RX_SNAKE.match(col): + rep.warn("E-DB-COL", where, + "column %s.%s should be snake_case" % (table, col)) + else: + rep.touch("E-DB-COL") + if "id" not in cols: + rep.warn("E-DB-ID", where, + "table %r has no 'id' column; the first auto-increment " + "primary key should be named 'id'" % table) + else: + rep.touch("E-DB-ID") + for fkm in re.finditer( + r"FOREIGN\s+KEY\s*\(\s*[`\"\[]?([A-Za-z0-9_]+)[`\"\]]?\s*\)\s*" + r"REFERENCES\s+[`\"\[]?([A-Za-z0-9_]+)", + body, re.IGNORECASE): + col, ref = fkm.group(1), fkm.group(2).split(".")[-1] + if col != "%s_id" % ref: + rep.warn("E-DB-FK", where, + "foreign key %s.%s references %s; name it %s_id" + % (table, col, ref, ref)) + else: + rep.touch("E-DB-FK") + + +def check_upload_readiness(rep, proj_dir, extra_roots=()): + """Documentation the Exchange upload form and the guide's checklist expect.""" + bases = [proj_dir] + [r for r in extra_roots if r] + for base in bases: + if not os.path.isdir(base): + continue + for fn in os.listdir(base): + if re.match(r"^(readme|install|installation|docs?|instructions)", + fn, re.IGNORECASE): + rep.touch("E-UP-DOCS") + return + rep.warn("E-UP-DOCS", "(resource root)", + "no README / installation document found; the Exchange upload form " + "asks for custom installation instructions") + + +# ------------------------------------------------------------------ discovery + +# Markers specific enough not to match a gateway's config/resources tree, which +# also carries com.inductiveautomation.* folders but is not a project. +PROJECT_MARKERS = ( + "project.json", + os.path.join(PERSPECTIVE, "views"), + os.path.join(PERSPECTIVE, "session-props"), + os.path.join(PERSPECTIVE, "page-config"), + os.path.join(VISION, "windows"), + os.path.join(VISION, "templates"), + os.path.join("ignition", "script-python"), +) +MAX_SCAN_DEPTH = 6 +SKIP_DIRS = {".git", ".svn", "__pycache__", "node_modules", ".resources", + ".venv", "venv", ".idea", ".vscode"} + + +def looks_like_project(d): + """True if d is the root of an Ignition 8.x project folder.""" + if not os.path.isdir(d): + return False + return any(os.path.exists(os.path.join(d, m)) for m in PROJECT_MARKERS) + + +def discover_projects(target): + """Return project directories under target. + + Accepts a project folder, any directory containing one or more projects + (a gateway's projects/ dir, an unpacked gateway backup, a repo), or a + project-export .zip. Nested projects are not searched once a project root + is found, so a projects/ dir yields its children, not their subfolders. + """ + target = os.path.abspath(target) + if os.path.isfile(target): + if target.lower().endswith(".zip"): + return discover_projects(unpack_zip(target)) + raise SystemExit("not a project folder or .zip export: %s" % target) + if not os.path.isdir(target): + raise SystemExit("no such path: %s" % target) + if looks_like_project(target): + return [target] + + found = [] + base_depth = target.rstrip(os.sep).count(os.sep) + for dirpath, dirs, _files in os.walk(target): + dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")] + if dirpath.count(os.sep) - base_depth >= MAX_SCAN_DEPTH: + dirs[:] = [] + continue + for d in list(dirs): + cand = os.path.join(dirpath, d) + if looks_like_project(cand): + found.append(cand) + dirs.remove(d) + return sorted(found) + + +def unpack_zip(path): + """Extract a project-export zip to a temp dir and return the project root.""" + import tempfile + import zipfile + dest = tempfile.mkdtemp(prefix="exchange_lint_") + with zipfile.ZipFile(path) as zf: + for member in zf.namelist(): + # refuse absolute paths and traversal in an untrusted archive + norm_m = os.path.normpath(member) + if os.path.isabs(norm_m) or norm_m.startswith(".."): + raise SystemExit("unsafe path in archive: %s" % member) + zf.extractall(dest) + entries = [os.path.join(dest, e) for e in os.listdir(dest)] + if len(entries) == 1 and os.path.isdir(entries[0]): + return entries[0] + return dest + + +def discover_aux(proj_dir, pattern_fn, limit=200): + """Collect auxiliary files (tag exports, .sql dumps) shipped beside a project.""" + out = [] + roots = [proj_dir, os.path.dirname(proj_dir.rstrip(os.sep))] + seen = set() + for base in roots: + if not os.path.isdir(base) or base in seen: + continue + seen.add(base) + for dirpath, dirs, files in os.walk(base): + dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")] + for fn in sorted(files): + p = os.path.join(dirpath, fn) + if p not in out and pattern_fn(p): + out.append(p) + if len(out) >= limit: + return out + return out + + +def is_tag_export(path): + if not path.lower().endswith(".json"): + return False + if os.path.basename(path) in ("project.json", "resource.json", "view.json", + "style.json", "props.json", "config.json"): + return False + try: + with open(path) as f: + head = f.read(4096) + except OSError: + return False + return '"tags"' in head and ('"tagType"' in head or '"valueSource"' in head + or '"tagGroup"' in head) + + +def is_sql_file(path): + return path.lower().endswith(".sql") + + +def infer_resource(proj_dir, proj_name): + """Best guess at the resource name from the view namespace, else the project.""" + for sub, payload in ((os.path.join(PERSPECTIVE, "views"), "view.json"), + (os.path.join(VISION, "windows"), "resource.json")): + paths = find_payload_dirs(os.path.join(proj_dir, sub), payload) + if not paths: + continue + tops = {} + for p in paths: + key = p[1] if len(p) > 1 and norm(p[0]) == "exchange" else p[0] + tops[key] = tops.get(key, 0) + 1 + return max(tops, key=lambda k: tops[k]) + return pascalize(proj_name) + + +# ---------------------------------------------------------------------- main + +def render(rep, proj_name, proj_dir, resource, as_json, show_header=True, + max_per_rule=8): + if as_json: + return { + "project": proj_name, + "path": proj_dir, + "resource": resource, + "failures": len(rep.fails), + "warnings": len(rep.warns), + "rules_exercised": sorted(rep.checked), + "findings": [{"severity": s, "rule": r, "section": RULE_SECTIONS.get(r, "?"), + "where": w, "message": m} for s, r, w, m in rep.findings], + } + if show_header: + print("Ignition Exchange Style Guide v1.1.0 — conformance report") + print("\nproject: %s resource: %s" % (proj_name, resource)) + print("path: %s" % proj_dir) + if not rep.findings: + print(" No findings.") + sections = [] + for _rid, sec, _desc in RULES: + if sec not in sections: + sections.append(sec) + for sec in sections: + rows = [f for f in rep.findings if RULE_SECTIONS.get(f[1]) == sec] + if not rows: + continue + print("\n %s" % sec) + rows.sort(key=lambda f: (f[0] != "FAIL", f[1], f[2])) + shown = {} + for sev, rule, where, msg in rows: + n = shown.get(rule, 0) + if max_per_rule and n >= max_per_rule: + shown[rule] = n + 1 + continue + shown[rule] = n + 1 + print(" %-4s %-13s %s: %s" % (sev, rule, where, msg)) + for rule, n in shown.items(): + if max_per_rule and n > max_per_rule: + print(" %-4s %-13s ... and %d more (-v for all)" + % ("", rule, n - max_per_rule)) + print("\n summary: %d failure(s), %d warning(s); %d of %d rules exercised" + % (len(rep.fails), len(rep.warns), len(rep.checked), len(RULES))) + return None + + +def lint_project(proj_dir, resource=None, tags=None, sql=None, ignore=None, + strict=False): + """Run every check against one project directory and return its Report.""" + proj_name = os.path.basename(proj_dir.rstrip(os.sep)) + resource = resource or infer_resource(proj_dir, proj_name) + tags = tags if tags is not None else discover_aux(proj_dir, is_tag_export) + sql = sql if sql is not None else discover_aux(proj_dir, is_sql_file) + + rep = Report(ignore=ignore, strict=strict) + check_project(rep, proj_dir, proj_name) + check_perspective(rep, proj_dir, resource) + check_vision(rep, proj_dir, resource) + check_scripts(rep, proj_dir, resource) + check_named_queries(rep, proj_dir, resource) + check_other_resources(rep, proj_dir, resource) + check_tags(rep, tags, resource) + check_database(rep, sql, resource) + check_upload_readiness(rep, proj_dir, + [os.path.dirname(proj_dir.rstrip(os.sep))]) + return rep, proj_name, resource + + +def main(argv=None): + ap = argparse.ArgumentParser( + description="Check an Ignition project against the Ignition Exchange " + "Resources Style Guide (v1.1.0).", + epilog="TARGET may be a project folder, a folder containing projects " + "(a gateway projects/ dir, an unpacked gateway backup, a repo), " + "or a project-export .zip. Defaults to the current directory.") + ap.add_argument("target", nargs="*", default=None, + help="project folder, containing folder, or .zip export") + ap.add_argument("--resource", help="Exchange resource name (default: inferred " + "from the resource namespace)") + ap.add_argument("--tags", nargs="*", default=None, + help="tag export .json files (default: auto-discovered)") + ap.add_argument("--sql", nargs="*", default=None, + help=".sql files to check (default: auto-discovered)") + ap.add_argument("--strict", action="store_true", help="treat warnings as failures") + ap.add_argument("--ignore", default="", help="comma-separated rule ids to suppress") + ap.add_argument("--json", action="store_true", dest="as_json", + help="machine-readable output") + ap.add_argument("--list-rules", action="store_true", + help="print the rule table and exit") + ap.add_argument("-v", "--verbose", action="store_true", + help="list every occurrence instead of collapsing per rule") + ap.add_argument("--max-per-rule", type=int, default=8, + help="occurrences shown per rule before collapsing (default 8)") + args = ap.parse_args(argv) + + if args.list_rules: + width = max(len(r[0]) for r in RULES) + for rid, sec, desc in RULES: + print("%-*s %-20s %s" % (width, rid, sec, desc)) + return 0 + + ignore = {r.strip() for r in args.ignore.split(",") if r.strip()} + unknown = ignore - VALID_RULES + if unknown: + print("unknown rule id(s) in --ignore: %s" % ", ".join(sorted(unknown)), + file=sys.stderr) + return 2 + + targets = args.target or [os.getcwd()] + projects = [] + try: + for t in targets: + for p in discover_projects(t): + if p not in projects: + projects.append(p) + except SystemExit as e: + print(e, file=sys.stderr) + return 2 + if not projects: + print("no Ignition project found under: %s" % ", ".join(targets), + file=sys.stderr) + return 2 + if args.resource and len(projects) > 1: + print("--resource applies to a single project; %d found" % len(projects), + file=sys.stderr) + return 2 + + results, total_fails = [], 0 + for i, proj_dir in enumerate(projects): + rep, proj_name, resource = lint_project( + proj_dir, resource=args.resource, tags=args.tags, sql=args.sql, + ignore=ignore, strict=args.strict) + total_fails += len(rep.fails) + payload = render(rep, proj_name, proj_dir, resource, args.as_json, + show_header=(i == 0), + max_per_rule=0 if args.verbose else args.max_per_rule) + if payload is not None: + results.append(payload) + + if args.as_json: + print(json.dumps({"projects": results, + "failures": total_fails}, indent=2)) + elif len(projects) > 1: + print("\n%d project(s) checked, %d total failure(s)" + % (len(projects), total_fails)) + return 1 if total_fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/exchangeResources/claude-skills/ignition-exchange-conformance/test_exchange_lint.py b/exchangeResources/claude-skills/ignition-exchange-conformance/test_exchange_lint.py new file mode 100644 index 0000000..4cf6c30 --- /dev/null +++ b/exchangeResources/claude-skills/ignition-exchange-conformance/test_exchange_lint.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""Self-test for exchange_lint.py. + +Builds two throwaway Ignition projects in a temp directory -- one that follows +the Exchange Resources Style Guide and one that breaks a known rule per check -- +then asserts the checker stays silent on the first and fires the expected rule +id on the second. This is what keeps the rules honest: a rule that cannot fail +and a rule that always fires both show up here. + +Run directly (python3 test_exchange_lint.py) or under pytest. +""" +import json +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import exchange_lint as el # noqa: E402 + +RESOURCE = "SampleResource" +PERSP = el.PERSPECTIVE + + +# ------------------------------------------------------------------ fixtures + +def write(path, text): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(text) + + +def write_json(path, obj): + write(path, json.dumps(obj, indent=2)) + + +RESOURCE_JSON = {"scope": "G", "version": 1, "restricted": False, + "overridable": True, "files": ["view.json"], "attributes": {}} + +CLEAN_CODE = '''"""Sample library module.""" + + +def formatCount(rawCount): + """Return rawCount rendered for display.""" + # pad small values so the column stays aligned + displayValue = "%d" % rawCount + return displayValue +''' + +DIRTY_CODE = '''def format_count(raw_count): + #no space after hash + display_value = "%d" % raw_count + return display_value +''' + + +def build_project(root, name, clean=True): + """Create a minimal Perspective project; clean=False breaks one rule per check.""" + proj = os.path.join(root, name) + ns_upper = "Exchange/%s" % RESOURCE if clean else "MyCompany" + ns_lower = "exchange/sample-resource" if clean else "MyCompany" + lib_pkg = "exchange/sampleresource" if clean else "MyCompany" + + write_json(os.path.join(proj, "project.json"), { + "title": "Sample Resource" if clean else "", + "description": "A sample Exchange resource." if clean else "", + "enabled": True, "inheritable": False, "parent": "", + }) + + view_dir = os.path.join(proj, PERSP, "views", *ns_upper.split("/"), "Dashboard") + write_json(os.path.join(view_dir, "resource.json"), RESOURCE_JSON) + write_json(os.path.join(view_dir, "view.json"), { + "params": {"startMs": 0} if clean else {"start_ms": 0}, + "custom": {}, + "root": { + "type": "ia.container.flex", + "meta": {"name": "root"}, + "props": {"style": {"classes": "%s/dashboard/card" % ns_lower + if clean else "%s/Card" % ns_lower}}, + "children": [{ + "type": "ia.display.label", + "meta": {"name": "TitleLabel" if clean else "titleLabel"}, + "custom": {"labelText": ""} if clean else {"label_text": ""}, + "props": {}, + "scripts": {"messageHandlers": [{ + "messageType": ("exchange.sampleResource.refresh" + if clean else "refresh")}]}, + }], + }, + }) + + style_dir = os.path.join(proj, PERSP, "style-classes", *ns_lower.split("/"), + *(("dashboard", "card") if clean else ("Card",))) + write_json(os.path.join(style_dir, "resource.json"), + dict(RESOURCE_JSON, files=["style.json"])) + write_json(os.path.join(style_dir, "style.json"), {"style": {}}) + + write_json(os.path.join(proj, PERSP, "page-config", "config.json"), + {"pages": {"/sample-resource" if clean else "/": { + "viewPath": "%s/Dashboard" % ns_upper}}, "sharedDocks": {}}) + + write_json(os.path.join(proj, PERSP, "session-props", "props.json"), + {"custom": {} if clean else {"MyCompany": {"selectedTab": 0}}, + "props": {}}) + + code_dir = os.path.join(proj, "ignition", "script-python", + *lib_pkg.split("/"), "fmt") + write_json(os.path.join(code_dir, "resource.json"), + dict(RESOURCE_JSON, scope="A", files=["code.py"])) + write(os.path.join(code_dir, "code.py"), CLEAN_CODE if clean else DIRTY_CODE) + + if clean: + write(os.path.join(proj, "README.md"), "# Sample Resource\n\nInstall steps.\n") + else: + vis_ctags = os.path.join(proj, el.VISION, "client-tags") + write_json(os.path.join(vis_ctags, "resource.json"), + dict(RESOURCE_JSON, files=["data.bin"])) + write(os.path.join(vis_ctags, "data.bin"), "x") + return proj + + +SQL_CLEAN = """ +CREATE TABLE ex_sr_contacts ( + id INT NOT NULL AUTO_INCREMENT, + contact_name VARCHAR(64), + PRIMARY KEY (id) +); +CREATE TABLE ex_sr_calls ( + id INT NOT NULL AUTO_INCREMENT, + ex_sr_contacts_id INT, + PRIMARY KEY (id), + FOREIGN KEY (ex_sr_contacts_id) REFERENCES ex_sr_contacts(id) +); +""" + +SQL_DIRTY = """ +CREATE TABLE Contacts ( + contactId INT NOT NULL AUTO_INCREMENT, + ContactName VARCHAR(64), + PRIMARY KEY (contactId) +); +CREATE TABLE ex_sr_calls ( + id INT NOT NULL AUTO_INCREMENT, + contact INT, + PRIMARY KEY (id), + FOREIGN KEY (contact) REFERENCES Contacts(contactId) +); +""" + +TAGS_CLEAN = {"tags": [{"name": "Exchange", "tagType": "Folder", "tags": [ + {"name": RESOURCE, "tagType": "Folder", "tags": [ + {"name": "AlarmCount", "tagType": "AtomicTag", "valueSource": "memory"}]}]}]} + +TAGS_DIRTY = {"tags": [{"name": "my_company", "tagType": "Folder", "tags": [ + {"name": "alarm_count", "tagType": "AtomicTag", "valueSource": "memory"}]}]} + + +# --------------------------------------------------------------------- tests + +class ExchangeLintTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.mkdtemp(prefix="exlint_test_") + cls.clean = build_project(cls.tmp, "sample-resource", clean=True) + cls.dirty = build_project(cls.tmp, "SampleResource_v2", clean=False) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmp, ignore_errors=True) + + def run_lint(self, proj, **kw): + kw.setdefault("tags", []) + kw.setdefault("sql", []) + kw.setdefault("resource", RESOURCE) + rep, _name, _res = el.lint_project(proj, **kw) + return rep + + def rules(self, rep): + return {f[1] for f in rep.findings} + + # -- the clean project must be silent --------------------------------- + + def test_clean_project_has_no_findings(self): + rep = self.run_lint(self.clean) + self.assertEqual(rep.findings, [], + "conforming project produced findings:\n" + + "\n".join("%s %s %s: %s" % f for f in rep.findings)) + + def test_clean_project_exits_zero(self): + rc = el.main([self.clean, "--resource", RESOURCE, "--tags", "--sql"]) + self.assertEqual(rc, 0) + + # -- the dirty project must fire each rule ---------------------------- + + def test_dirty_project_fires_expected_rules(self): + got = self.rules(self.run_lint(self.dirty)) + for rule in ("E-PROJ-NAME", "E-PROJ-TITLE", "E-PROJ-DESC", + "E-NS-ROOT", "E-COMP-NAME", "E-PROP-NAME", + "E-PAGE-ROOT", "E-STYLE-NAME", "E-MSG-NAME", + "E-STORE-SESS", "E-STORE-CTAG", + "E-PY-TABS", "E-PY-FUNC", "E-PY-VAR", "E-PY-DOC", + "E-PY-COMMENT", "E-UP-DOCS"): + self.assertIn(rule, got, "expected %s to fire" % rule) + + def test_dirty_project_exits_nonzero(self): + rc = el.main([self.dirty, "--resource", RESOURCE, "--tags", "--sql"]) + self.assertEqual(rc, 1) + + # -- severity model --------------------------------------------------- + + def test_namespace_and_storage_are_failures(self): + rep = self.run_lint(self.dirty) + fails = {f[1] for f in rep.fails} + for rule in ("E-NS-ROOT", "E-STORE-SESS", "E-STORE-CTAG", "E-PROJ-NAME"): + self.assertIn(rule, fails, "%s should be a FAIL, not a warning" % rule) + + def test_naming_rules_are_warnings_by_default(self): + rep = self.run_lint(self.dirty) + warns = {f[1] for f in rep.warns} + for rule in ("E-COMP-NAME", "E-PY-FUNC", "E-PY-TABS"): + self.assertIn(rule, warns, "%s should warn by default" % rule) + + def test_strict_promotes_warnings(self): + rep = self.run_lint(self.dirty, strict=True) + self.assertEqual(rep.warns, [], "--strict left warnings behind") + self.assertIn("E-COMP-NAME", {f[1] for f in rep.fails}) + + def test_ignore_suppresses_a_rule(self): + rep = self.run_lint(self.dirty, ignore={"E-COMP-NAME"}) + self.assertNotIn("E-COMP-NAME", self.rules(rep)) + + # -- database and tag checks ----------------------------------------- + + def test_sql_clean_and_dirty(self): + clean_sql = os.path.join(self.tmp, "clean.sql") + dirty_sql = os.path.join(self.tmp, "dirty.sql") + write(clean_sql, SQL_CLEAN) + write(dirty_sql, SQL_DIRTY) + + rep = el.Report() + el.check_database(rep, [clean_sql], RESOURCE) + self.assertEqual(rep.findings, [], + "conforming schema produced findings: %s" % rep.findings) + + rep = el.Report() + el.check_database(rep, [dirty_sql], RESOURCE) + got = {f[1] for f in rep.findings} + for rule in ("E-DB-TABLE", "E-DB-COL", "E-DB-ID", "E-DB-FK"): + self.assertIn(rule, got, "expected %s to fire" % rule) + + def test_tags_clean_and_dirty(self): + clean_tags = os.path.join(self.tmp, "clean-tags.json") + dirty_tags = os.path.join(self.tmp, "dirty-tags.json") + write_json(clean_tags, TAGS_CLEAN) + write_json(dirty_tags, TAGS_DIRTY) + + rep = el.Report() + el.check_tags(rep, [clean_tags], RESOURCE) + self.assertEqual(rep.findings, [], + "conforming tag export produced findings: %s" % rep.findings) + + rep = el.Report() + el.check_tags(rep, [dirty_tags], RESOURCE) + got = {f[1] for f in rep.findings} + self.assertIn("E-NS-ROOT", got) + self.assertIn("E-TAG-NAME", got) + + # -- discovery -------------------------------------------------------- + + def test_discovers_both_projects_from_a_containing_folder(self): + found = el.discover_projects(self.tmp) + self.assertEqual(sorted(os.path.basename(p) for p in found), + ["SampleResource_v2", "sample-resource"]) + + def test_project_folder_resolves_to_itself(self): + self.assertEqual(el.discover_projects(self.clean), [self.clean]) + + def test_zip_export_is_unpacked(self): + archive = shutil.make_archive( + os.path.join(self.tmp, "export"), "zip", + root_dir=os.path.dirname(self.clean), + base_dir=os.path.basename(self.clean)) + found = el.discover_projects(archive) + self.assertEqual(len(found), 1) + self.assertTrue(os.path.exists(os.path.join(found[0], "project.json"))) + + def test_resource_name_inferred_from_namespace(self): + self.assertEqual(el.infer_resource(self.clean, "sample-resource"), RESOURCE) + + # -- name predicates -------------------------------------------------- + + def test_name_predicates(self): + self.assertTrue(el.is_pascal("AreaCard")) + self.assertFalse(el.is_pascal("areaCard")) + self.assertTrue(el.is_camel("startMs")) + self.assertFalse(el.is_camel("start_ms")) + self.assertTrue(el.is_kebab("bad-actors")) + self.assertFalse(el.is_kebab("BadActors")) + self.assertTrue(el.is_title("Alarm Dashboard")) + self.assertTrue(el.is_title("Table of Contents")) + self.assertFalse(el.is_title("alarm dashboard")) + self.assertTrue(el.is_title_or_pascal("AlarmDashboard")) + + def test_rule_table_ids_are_unique_and_referenced(self): + ids = [r[0] for r in el.RULES] + self.assertEqual(len(ids), len(set(ids)), "duplicate rule id in RULES") + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), + "exchange_lint.py")) as f: + source = f.read() + for rid in ids: + self.assertGreaterEqual(source.count('"%s"' % rid), 2, + "%s is declared but never raised" % rid) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/exchangeResources/claude-skills/ignition-exchange-convert/SKILL.md b/exchangeResources/claude-skills/ignition-exchange-convert/SKILL.md new file mode 100644 index 0000000..a6d7240 --- /dev/null +++ b/exchangeResources/claude-skills/ignition-exchange-convert/SKILL.md @@ -0,0 +1,180 @@ +--- +name: ignition-exchange-convert +description: Convert an existing Ignition project into a publishable Exchange resource — re-namespacing views/styles/scripts, migrating Perspective session properties to view properties, renaming Python to the guide's conventions — and prove the conversion did not break anything. Use when asked to make a project Exchange-ready, de-brand a project for publication, or refactor one out of session-scoped state. Bundles verify_refs.py and parity.py, which catch the two failure modes a conformance checker cannot see: dangling references and silently dropped features. Not for merely checking conformance (use ignition-exchange-conformance) or uploading (use ignition-exchange-publish). +--- + +# Converting a project into an Exchange resource + +A conversion is a large mechanical rename plus one genuine refactor. It is +**very easy to produce a project that is perfectly named and completely broken** — +so the verification gates below are not optional garnish, they are the point. + +Check the target shape first with +[`ignition-exchange-conformance`](../ignition-exchange-conformance/SKILL.md); +this skill is what to do about the findings. + +## Work on a copy + +Never convert in place. Copy the project to its new name, leave the original +untouched, and diff against it at the end. The original is your parity baseline — +without it you cannot prove you did not drop a feature. + +Ignition project folder name **is** the project name, so the copy's directory +must already be the new lowercase-dashed name. + +## Order matters + +Do it in this order. Step 2 invalidates bindings that step 3 would otherwise +have to rewrite twice. + +1. **Delete what must not ship.** Vision `client-tags/`, scratch views, dev-only + resources. +2. **Session properties → view properties**, while paths are still the familiar + old ones. Verify the app still works before touching namespaces. +3. **Re-namespace** views, style classes, scripts, page config — one mechanical + sweep. +4. **Python pass** — tabs, camelCase, docstrings, loggers. +5. **Content sweep** — branding, internal names, placeholder text. +6. **Verify** (gates below), then package. + +## Step 2 is the real refactor + +Everything else is search-and-replace. This one changes how the app works. + +The guide forbids session custom properties in an Exchange resource because +importing merges them into the host project's session props. Replace with: + +- **State lives on the root view** as `custom` properties. +- **Flows down** to child views as `params` (the guide is explicit: `params` = + public/configuration, `custom` = internal). +- **Flows up** as page-scoped messages handled on the root view, named + `exchange.resourceName.handlerName`. + +**Bindings must stay reactive.** Do not park binding-read state in +`system.util.getGlobals()` — globals do not trigger Perspective binding updates. +Globals are only for non-reactive cross-session data, and most resources need +none. + +Before rewriting a binding, check **what it actually depended on**. A bidirectional +binding that writes a session prop does not necessarily re-trigger the data fetch; +if the fetch keyed off a `refreshToken`, then an explicit Apply button was already +the commit point and your message-based version preserves behaviour exactly. Read +the dependency graph rather than assuming. + +## Jython 2.7 constraints + +Gateway-scoped code is Jython 2.7. Violations are silent until they reach the gateway. + +- **No f-strings.** `%` formatting only. +- No `typing`, `statistics`, `zoneinfo`. +- Java exceptions bypass `except Exception:` — use bare `except:` with + `sys.exc_info()` in gateway-facing defensive code. +- **Scripts embedded in `view.json`** are JSON strings that must be tab-indented: + the first character of every line in the block is a tab. + +## Python renames + +The guide wants camelCase functions and variables (deliberately not PEP-8) and +tab indentation. Two traps: + +- **Rename through the tokenizer, not regex.** A blind replace will corrupt + string literals and attribute names that happen to match (`lt.tm_wday`). +- **Do not rename data-dictionary keys.** Keys inside dicts returned to view + bindings are a wire format between the scripts and every binding that reads + them. They are not covered by any rule and renaming them is a large, risky + diff for no conformance gain. + +Tab conversion applies to **indentation** only — bracket continuation lines +starting with spaces are fine and normal. Verify with the tokenizer's INDENT +tokens, not by grepping for leading spaces. + +## Verification gates + +Run all four. Each catches something the others cannot. + +### 1 · Conformance + +```bash +python3 skills/ignition-exchange-conformance/exchange_lint.py +``` +Target: **0 failures**. Warnings are a judgement call. + +### 2 · Referential integrity + +```bash +python3 skills/ignition-exchange-convert/verify_refs.py --old +``` + +Resolves every embedded view path, style-class reference, `view.params.X` / +`view.custom.X`, and every call into the project library against the functions +that actually exist. Also flags leftover old-namespace strings, surviving +`session.custom` reads, untabbed script transforms, f-strings, and `resource.json` +drift. Target: **0 breakages**. + +With ~40 function renames, a missed call site is the single most likely defect, +and nothing else finds it. + +### 3 · Feature parity + +```bash +python3 skills/ignition-exchange-convert/parity.py +``` + +Fingerprints every view — component types and names, bindings by target property +and type, script transforms, event and message handlers, embedded views, declared +params — with names normalised for case, punctuation and snake/camel. **A pure +rename cancels out; a dropped feature does not.** + +Target: **zero `LOST` or `MISSING` lines.** `ADDED` lines are expected — the +session-props refactor legitimately adds params and handlers. Read the diff, do +not just count it. + +### 4 · Behaviour + +If the project has a test suite over its pure modules, run it against the +converted library. Point the package name at the new location and alias +camelCase functions **and their keyword arguments** back to the original names — +a renamed kwarg (`higher_is_worse` → `higherIsWorse`) breaks callers that pass it +by name, which no static check catches. + +Compare against the **original project's** result, not against zero failures. A +pre-existing failure staying pre-existing is a pass. + +### Then: load it on a gateway + +Static analysis cannot prove a screen renders. Load the project and open it. +A clean project load proves resources parse; only a browser proves components +render. + +## De-branding + +Conformance checkers read names, never label text. Sweep user-visible strings +explicitly — `props.text`, `title`, `placeholder`, `tooltip` — for company names, +project codenames, contest or demo references, internal hostnames, ticket numbers. +Strip the text rather than deleting the component, so layout and parity are +unchanged. + +## Testing a minimum-version claim + +To claim a floor lower than the gateway you built on, load the project on that +version — **against a copy of the files**. An older gateway rewrites every +`resource.json` with `lastModification` metadata and leaves root-owned +`.resources/` artifacts behind that your user account cannot delete (clean up +with a throwaway container rather than sudo). + +A clean load proves resources parse. It does **not** prove screens render — a +component property introduced in a later version fails at render time, not load +time. Open the client. + +## Reporting + +Report what the gates actually said, with numbers. A conversion reported +accurately with two known gaps is worth far more than one claimed clean. If a +gate is a false positive, say which and why — the tooling is not infallible and +both bundled scripts have known blind spots documented in their docstrings. + +## Provenance + +Procedure and both scripts derived from a full conversion of a 20-view / 32-style +/ 3-module Perspective project (2026-09-15), verified on Ignition 8.1.20 and +8.3.7. The gate thresholds are the ones that actually caught defects during it. diff --git a/exchangeResources/claude-skills/ignition-exchange-convert/parity.py b/exchangeResources/claude-skills/ignition-exchange-convert/parity.py new file mode 100644 index 0000000..e543406 --- /dev/null +++ b/exchangeResources/claude-skills/ignition-exchange-convert/parity.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Functional-parity check between an original Ignition project and a converted copy. + +Renaming is supposed to be behaviour-preserving. This compares what the two +projects DO, ignoring what things are called: every view, every component, every +binding, every event/message handler, every embedded view and style reference is +fingerprinted with names normalised (case, punctuation, snake/camel folded) so a +pure rename cancels out and a DROPPED FEATURE does not. + +Usage: python3 parity.py +""" +import json +import os +import re +import sys +from collections import Counter + +PERSP = "com.inductiveautomation.perspective" +EMBED = {"ia.display.view", "ia.display.flex-repeater"} +DIFFS = [] + + +def diff(scope, msg): + DIFFS.append((scope, msg)) + + +def norm(s): + """Fold a name to bare lowercase alphanumerics so renames cancel out.""" + return re.sub(r"[^a-z0-9]", "", str(s).lower()) + + +def segs(path): + return [p for p in str(path).replace("\\", "/").split("/") if p] + + +def common_prefix_len(paths): + """How many leading segments every resource shares -- i.e. the namespace. + + Never eats the final segment, so a single-resource project still has a name. + """ + lists = [segs(p) for p in paths if segs(p)] + if not lists: + return 0 + n = 0 + while True: + if any(len(l) <= n + 1 for l in lists): + break + first = lists[0][n] + if any(l[n] != first for l in lists): + break + n += 1 + return n + + +def key_of(path, strip): + """Namespace-stripped identity: folds case, punctuation and re-foldering.""" + return norm("".join(segs(path)[strip:])) + + +def collect(base, payload): + out = {} + if not os.path.isdir(base): + return out + for dp, _d, fs in os.walk(base): + if payload in fs: + rel = os.path.relpath(dp, base).replace(os.sep, "/") + if rel != ".": + out[rel] = os.path.join(dp, payload) + return out + + +def walk(node, visit, path="root"): + if not isinstance(node, dict): + return + visit(node, path) + for i, c in enumerate(node.get("children") or []): + n = (c.get("meta") or {}).get("name", "?") if isinstance(c, dict) else "?" + walk(c, visit, "%s/%s[%d]" % (path, n, i)) + + +def view_fingerprint(vfile, embed_key): + """A behaviour fingerprint for one view, insensitive to naming.""" + view = json.load(open(vfile, encoding="utf-8")) + fp = { + "types": Counter(), + "names": Counter(), + "bindings": Counter(), + "transforms": Counter(), + "events": Counter(), + "handlers": Counter(), + "embeds": Counter(), + "stylerefs": 0, + "params": set(), + "components": 0, + } + + def record_props(node, owner): + for key, cfg in (node.get("propConfig") or {}).items(): + b = (cfg or {}).get("binding") + if not b: + continue + # the bound property is the feature; its binding type is how it is fed + fp["bindings"][(norm(key), b.get("type"))] += 1 + for tr in (b.get("transforms") or []): + fp["transforms"][tr.get("type")] += 1 + scripts = node.get("scripts") or {} + for ev in (scripts.get("customMethods") or []): + fp["events"][("method", norm(ev.get("name")))] += 1 + for mh in (scripts.get("messageHandlers") or []): + fp["handlers"][norm((mh.get("messageType") or "").split(".")[-1])] += 1 + for comp in (scripts.get("extensionFunctions") or {}): + fp["events"][("ext", norm(comp))] += 1 + events = node.get("events") or {} + for domain, acts in events.items(): + if isinstance(acts, dict): + for act in acts: + fp["events"][(norm(domain), norm(act))] += 1 + + record_props(view, "view") + for k in (view.get("propConfig") or {}): + if k.startswith("params."): + fp["params"].add(norm(k.split(".")[1].split("[")[0])) + for k in (view.get("params") or {}): + fp["params"].add(norm(k)) + + def visit(node, cpath): + if cpath != "root": + fp["components"] += 1 + fp["types"][node.get("type")] += 1 + nm = (node.get("meta") or {}).get("name") + if nm: + fp["names"][norm(nm)] += 1 + record_props(node, cpath) + if node.get("type") in EMBED: + p = (node.get("props") or {}).get("path") + if isinstance(p, str) and p: + fp["embeds"][embed_key(p)] += 1 + cls = ((node.get("props") or {}).get("style") or {}).get("classes") + if isinstance(cls, str): + fp["stylerefs"] += len([c for c in cls.split() if c]) + walk(view.get("root") or {}, visit) + return fp + + +def compare_counter(scope, label, a, b): + lost = a - b + gained = b - a + for k, n in sorted(lost.items(), key=lambda kv: str(kv[0])): + diff(scope, "LOST %s %s x%d" % (label, k, n)) + for k, n in sorted(gained.items(), key=lambda kv: str(kv[0])): + diff(scope, "ADDED %s %s x%d" % (label, k, n)) + + +def main(): + old_p, new_p = os.path.abspath(sys.argv[1]), os.path.abspath(sys.argv[2]) + + old_views = collect(os.path.join(old_p, PERSP, "views"), "view.json") + new_views = collect(os.path.join(new_p, PERSP, "views"), "view.json") + old_styles = collect(os.path.join(old_p, PERSP, "style-classes"), "style.json") + new_styles = collect(os.path.join(new_p, PERSP, "style-classes"), "style.json") + + # Strip each project's own namespace so a pure re-namespacing cancels out. + ov_strip = common_prefix_len(old_views) + nv_strip = common_prefix_len(new_views) + os_strip = common_prefix_len(old_styles) + ns_strip = common_prefix_len(new_styles) + + def index(d, strip): + out = {} + for k, v in d.items(): + out.setdefault(key_of(k, strip), []).append((k, v)) + return out + + oi, ni = index(old_views, ov_strip), index(new_views, nv_strip) + for key in sorted(set(oi) - set(ni)): + diff("views", "MISSING view '%s' (no counterpart for %s)" + % (key, oi[key][0][0])) + for key in sorted(set(ni) - set(oi)): + diff("views", "EXTRA view '%s' (%s)" % (key, ni[key][0][0])) + + # Style classes legitimately change shape (Grade/A -> grade-a, and + # E-STYLE-HIER re-folders them), so identity matching would be noise. + # What must not change is that none disappeared -- and verify_refs.py + # separately proves every remaining reference still resolves. + if len(old_styles) != len(new_styles): + diff("styles", "style class count %d -> %d" % (len(old_styles), len(new_styles))) + okeys = {key_of(k, os_strip) for k in old_styles} + nkeys = {key_of(k, ns_strip) for k in new_styles} + for k in sorted(okeys - nkeys): + diff("styles", "no class resembling '%s' in the new project" % k) + + # ---- per-view behaviour --------------------------------------------- + for key in sorted(set(oi) & set(ni)): + ofp = view_fingerprint(oi[key][0][1], lambda p: key_of(p, ov_strip)) + nfp = view_fingerprint(ni[key][0][1], lambda p: key_of(p, nv_strip)) + scope = "view %s" % key + if ofp["components"] != nfp["components"]: + diff(scope, "component count %d -> %d" + % (ofp["components"], nfp["components"])) + if ofp["stylerefs"] != nfp["stylerefs"]: + diff(scope, "style class references %d -> %d" + % (ofp["stylerefs"], nfp["stylerefs"])) + for label in ("types", "names", "bindings", "transforms", + "events", "handlers", "embeds"): + compare_counter(scope, label[:-1] if label.endswith("s") else label, + ofp[label], nfp[label]) + lost_params = ofp["params"] - nfp["params"] + if lost_params: + diff(scope, "params no longer declared: %s" % ", ".join(sorted(lost_params))) + + # ---- library surface ------------------------------------------------- + def lib(root): + out = {} + base = os.path.join(root, "ignition", "script-python") + for dp, _d, fs in os.walk(base): + if "code.py" not in fs: + continue + mod = os.path.basename(dp) + src = open(os.path.join(dp, "code.py"), encoding="utf-8", + errors="replace").read() + fns = {} + for m in re.finditer(r"^def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)", + src, re.M): + args = [a.strip().split("=")[0].strip() + for a in m.group(2).split(",") if a.strip()] + fns[norm(m.group(1))] = len(args) + out[norm(mod)] = fns + return out + + ol, nl = lib(old_p), lib(new_p) + for mod in sorted(set(ol) - set(nl)): + diff("library", "MISSING module %s" % mod) + for mod in sorted(set(ol) & set(nl)): + for fn in sorted(set(ol[mod]) - set(nl[mod])): + diff("library", "MISSING function %s.%s" % (mod, fn)) + for fn in sorted(set(ol[mod]) & set(nl[mod])): + if ol[mod][fn] != nl[mod][fn]: + diff("library", "arity change %s.%s: %d -> %d args" + % (mod, fn, ol[mod][fn], nl[mod][fn])) + + # ---- pages ----------------------------------------------------------- + def pages(root): + p = os.path.join(root, PERSP, "page-config", "config.json") + if not os.path.exists(p): + return {} + return (json.load(open(p)) or {}).get("pages") or {} + op, np_ = pages(old_p), pages(new_p) + if len(op) != len(np_): + diff("pages", "page count %d -> %d" % (len(op), len(np_))) + ot = {key_of(v.get("viewPath", ""), ov_strip) for v in op.values()} + nt = {key_of(v.get("viewPath", ""), nv_strip) for v in np_.values()} + for k in sorted(ot - nt): + diff("pages", "no page maps to view %s any more" % k) + + for scope, msg in DIFFS: + print("DIFF %-28s %s" % (scope, msg)) + print("parity: %d view(s) vs %d, %d style(s) vs %d, %d difference(s)" + % (len(old_views), len(new_views), len(old_styles), len(new_styles), + len(DIFFS))) + return 1 if DIFFS else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/exchangeResources/claude-skills/ignition-exchange-convert/verify_refs.py b/exchangeResources/claude-skills/ignition-exchange-convert/verify_refs.py new file mode 100644 index 0000000..4d8d524 --- /dev/null +++ b/exchangeResources/claude-skills/ignition-exchange-convert/verify_refs.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Referential-integrity check for a migrated Ignition project. + +The style checker tests NAMES. This tests that everything still POINTS somewhere +real after a namespace + function rename: embedded views, style classes, view +params, and every call into the project library. A migration can be perfectly +named and completely broken; this is the half that catches that. + +Usage: python3 verify_refs.py [--old OldNamespace] +""" +import ast +import json +import os +import re +import sys + +PERSP = "com.inductiveautomation.perspective" +EMBED = {"ia.display.view", "ia.display.flex-repeater"} +FAILS = [] + + +def fail(where, msg): + FAILS.append((where, msg)) + + +def walk(node, visit, path="root"): + if not isinstance(node, dict): + return + visit(node, path) + for i, c in enumerate(node.get("children") or []): + n = (c.get("meta") or {}).get("name", "?") if isinstance(c, dict) else "?" + walk(c, visit, "%s/%s[%d]" % (path, n, i)) + + +def main(): + proj = os.path.abspath(sys.argv[1]) + # The project's previous namespace, e.g. "MyCompany". Any surviving mention + # of it in the converted project is a missed rename. + old = "" + if "--old" in sys.argv: + old = sys.argv[sys.argv.index("--old") + 1] + + views_root = os.path.join(proj, PERSP, "views") + styles_root = os.path.join(proj, PERSP, "style-classes") + sp_root = os.path.join(proj, "ignition", "script-python") + + def collect(base, payload): + out = set() + for dp, _d, fs in os.walk(base): + if payload in fs: + r = os.path.relpath(dp, base) + if r != ".": + out.add(r.replace(os.sep, "/")) + return out + + views = collect(views_root, "view.json") + styles = collect(styles_root, "style.json") + + # ---- project library: package path -> {function names} ---------------- + lib = {} + for dp, _d, fs in os.walk(sp_root): + if "code.py" not in fs: + continue + pkg = os.path.relpath(dp, sp_root).replace(os.sep, ".") + src = open(os.path.join(dp, "code.py"), encoding="utf-8", + errors="replace").read() + try: + tree = ast.parse(src) + lib[pkg] = {n.name for n in tree.body + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))} + except SyntaxError as e: + fail(pkg + "/code.py", "does not parse: line %s %s" % (e.lineno, e.msg)) + lib[pkg] = set() + if re.search(r"""(^|[^A-Za-z0-9_'"])[fF]["']""", src): + fail(pkg + "/code.py", "f-string present (Jython 2.7 cannot parse)") + + lib_roots = {p.split(".")[0] for p in lib} + + # ---- per view --------------------------------------------------------- + for vp in sorted(views): + vfile = os.path.join(views_root, vp.replace("/", os.sep), "view.json") + where = "views/" + vp + raw = open(vfile, encoding="utf-8", errors="replace").read() + try: + view = json.loads(raw) + except Exception as e: + fail(where, "invalid JSON: %s" % e) + continue + + if old and old in raw: + hits = len(re.findall(re.escape(old), raw)) + fail(where, "%d leftover reference(s) to old namespace %r" % (hits, old)) + if "session.custom" in raw: + fail(where, "still reads session.custom (Exchange resources must not)") + + declared = set((view.get("params") or {}).keys()) + declared_custom = set((view.get("custom") or {}).keys()) + for k in (view.get("propConfig") or {}): + if k.startswith("params."): + declared.add(k.split(".")[1].split("[")[0]) + elif k.startswith("custom."): + declared_custom.add(k.split(".")[1].split("[")[0]) + + def visit(node, cpath): + if node.get("type") in EMBED: + p = (node.get("props") or {}).get("path") + bound = "props.path" in (node.get("propConfig") or {}) + if isinstance(p, str) and p: + if p not in views: + fail(where, "%s embeds missing view %r" % (cpath, p)) + elif not bound: + pass + cls = ((node.get("props") or {}).get("style") or {}).get("classes") + if isinstance(cls, str): + for c in cls.split(): + if c and c not in styles: + fail(where, "%s references missing style class %r" % (cpath, c)) + walk(view.get("root") or {}, visit) + + # bindings: params/custom resolution, tab indent, library calls + def each_cfg(): + for k, v in (view.get("propConfig") or {}).items(): + yield "view." + k, v + acc = [] + walk(view.get("root") or {}, + lambda n, p: [acc.append(("%s %s" % (p, k), v)) + for k, v in (n.get("propConfig") or {}).items()]) + for it in acc: + yield it + + for owner, cfg in each_cfg(): + b = (cfg or {}).get("binding") + if not b: + continue + blobs = [json.dumps(b.get("config") or {})] + for tr in b.get("transforms") or []: + code = tr.get("code", "") + blobs.append(code) + if tr.get("type") == "script" and code and not code.startswith(("\t", "\n")): + fail(where, "%s: script transform not tab-indented" % owner) + blob = " ".join(blobs) + for m in re.finditer(r"view\.params\.([A-Za-z0-9_]+)", blob): + if m.group(1) not in declared: + fail(where, "%s: undeclared view param %r" % (owner, m.group(1))) + for m in re.finditer(r"view\.custom\.([A-Za-z0-9_]+)", blob): + if m.group(1) not in declared_custom: + fail(where, "%s: undeclared view custom prop %r" % (owner, m.group(1))) + + check_lib_calls(raw, where, lib, lib_roots) + + # ---- library calling itself ------------------------------------------ + for dp, _d, fs in os.walk(sp_root): + if "code.py" not in fs: + continue + p = os.path.join(dp, "code.py") + rel = os.path.relpath(p, proj) + src = open(p, encoding="utf-8", errors="replace").read() + if old and old in src: + fail(rel, "leftover reference to old namespace %r" % old) + check_lib_calls(src, rel, lib, lib_roots) + + # ---- resource.json integrity ----------------------------------------- + for dp, ds, fs in os.walk(proj): + if ".resources" in ds: + fail(os.path.relpath(dp, proj), "authored .resources directory") + ds.remove(".resources") + if "resource.json" not in fs: + continue + rel = os.path.relpath(dp, proj) + try: + res = json.load(open(os.path.join(dp, "resource.json"))) + except Exception as e: + fail(rel, "invalid resource.json: %s" % e) + continue + declared = res.get("files") or [] + payloads = [f for f in fs if f != "resource.json"] + for f in declared: + if f not in payloads: + fail(rel, "resource.json declares missing file %r" % f) + for f in payloads: + if f not in declared: + fail(rel, "undeclared payload file %r" % f) + want = "A" if ("script-python" in rel or "global-props" in rel) else "G" + if res.get("scope") != want: + fail(rel, "scope %r, expected %r" % (res.get("scope"), want)) + + for where, msg in FAILS: + print("BROKEN %s: %s" % (where, msg)) + print("refs: %d view(s), %d style class(es), %d library module(s), %d breakage(s)" + % (len(views), len(styles), len(lib), len(FAILS))) + return 1 if FAILS else 0 + + +def check_lib_calls(text, where, lib, lib_roots): + """Every .. reference must resolve to a real function.""" + for root in lib_roots: + # (?.), which are + # strings, not code -- flagging those would be a false positive. + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/exchangeResources/claude-skills/ignition-exchange-publish/SKILL.md b/exchangeResources/claude-skills/ignition-exchange-publish/SKILL.md new file mode 100644 index 0000000..1017d3e --- /dev/null +++ b/exchangeResources/claude-skills/ignition-exchange-publish/SKILL.md @@ -0,0 +1,130 @@ +--- +name: ignition-exchange-publish +description: Package and upload a finished Ignition resource to the Ignition Exchange — which file format to export, what to verify inside the archive before uploading, and what to put in every field of the Exchange upload form (title, tagline, categories, version, minimum Ignition platform, required modules, package files, custom installation instructions). Use when asked how to publish, export, or upload an Ignition project to the Exchange, or what format the Exchange expects. Not for checking conformance (use ignition-exchange-conformance) or converting a project into Exchange shape (use ignition-exchange-convert). +--- + +# Publishing to the Ignition Exchange + +## The artifact is a project export `.zip` + +Produced by **Designer → File → Export**, or **Gateway → Config → Projects → +*project* → Export**. Both give the same file. + +> **Never upload a gateway backup (`.gwbk`).** It contains the entire gateway — +> datasource credentials, journal profiles, tags, every other project. Wrong +> artifact, and it leaks configuration into a public resource. + +A repo that stores projects as unpacked files (the gateway-as-files pattern) has +no zip in it — one has to be generated. + +## What goes in the upload + +The Exchange accepts multiple package files, 10 MB each. + +| File | Upload as | When | +|---|---|---| +| `.zip` | Package file | Always — the project export | +| `README.md` / PDF | Package file | Always, as a **separate** file | +| Tag export `.json` | Package file | Only if it needs tags/UDTs | +| Database dump `.sql` | Package file | Only if it needs a schema or seed data | +| Screenshots | Images & Screenshots | Optional, strongly encouraged | + +**The README is probably not inside the zip.** Ignition's export serialises +recognised project resources — each is a directory with a `resource.json`. A +loose `README.md` at the project root is not one, so the export is expected to +drop it. Unzip and check rather than assuming. The guide asks for docs as a +separate package file regardless: + +> **Note:** If you already have detailed documentation for your resource please +> note it here and include the documentation file in the Package File upload. + +Note that a conformance checker's documentation rule inspects the working +directory, not the archive — a passing lint does not prove the zip carries docs. + +## Before packaging + +The guide names four pre-upload checks. Three are often N/A; the first never is. + +1. **Check the project export** — import the zip into a brand-new project, *and* + into a project that already has content. The second is the case the whole + namespacing convention exists for and the only way to prove nothing collides. +2. **Include the tags** — if it binds to tags or UDTs, export them and verify + coverage. +3. **Database backups** — if it expects tables or seed data, include the dump and + name the vendor (MySQL / MSSQL / PostgreSQL) in both the description and the + install instructions. +4. **Take notes while testing** — they become the Custom Installation + Instructions. Readers range from beginner to intermediate. + +## Verify the archive + +```bash +unzip -l .zip +unzip -o .zip -d /tmp/verify-export +``` + +- File count matches the project on disk, minus anything the export legitimately drops +- Every view, style class and script module present +- `README.md` presence — confirm, do not assume +- No `.resources/` (gateway-owned cache), `__pycache__`, editor backups +- No credentials anywhere + +## The upload form + +### Overview + +| Field | Guidance | +|---|---| +| Visibility | `Public` unless genuinely private | +| Title | Short, Title Case, says what it does; usually matches project title | +| Tagline | One sentence, must **not** repeat the title | +| Description | What it does, how it's used, who benefits, relevant industries | +| Resource Type | Closest dropdown match + skill level for install/use complexity | +| Background Image | Optional card styling | +| Category | **Up to 3**: Alarming, Analytics, Connectivity, Dashboard, Diagnostics, Edge Computing, Enterprise, HMI, IIOT/MQTT, MES, Maintenance, Mapping/GIS, Mobile, Monitoring, Reporting, SCADA, Scripting, Security, Simulation, Trending/Charting, Utility | +| Contact the Developer | Optional; your address is never shown | +| Tags | **Up to 10** search keywords | +| Images & Screenshots | Optional, highly encouraged | + +### Package + +| Field | Guidance | +|---|---| +| Version | `1.0.0` first release; later auto-increment. **Never** put the version in the project name | +| Ignition Platform | The **minimum** required. Guide: *"If unsure, select the Ignition platform version in which the package was developed with."* Do not claim an untested floor | +| Release Tagline | Short summary of this version's changes; required even for the first | +| Release Notes | Fuller explanation of what changed and its impact | +| Required Modules | IA / Cirrus Link / Sepasoft modules needed | +| Maker Edition | Tick if it runs on Maker; unavailable if you require unsupported modules | +| Other Requirements | External prerequisites, one per line; blank if none | +| Package Files | The zip plus docs / tags / SQL | +| Custom Installation Instructions | Step-by-step; assume a beginner. If you shipped separate docs, say so here | + +The Exchange **auto-generates a readme** from the Description and Custom +Installation Instructions — write both properly. + +## Verifying a minimum-version claim + +Load the project on a throwaway gateway at the version you intend to declare, +using a **copy** of the files — an older gateway rewrites `resource.json` +metadata and leaves root-owned `.resources/` behind. + +A clean load proves resources parse. It does **not** prove screens render: a +component property added in a later version fails at render time. Open the +client and look. If you cannot test the lower version, declare the version you +developed on. + +## After publishing + +- Updates are **new package versions on the same resource**, not new resources. +- **Never rename the namespace folders between versions** — that turns an upgrade + into a duplicate set of resources in every user's project. +- Re-run conformance before every release. +- Non-English resources: the guide encourages shipping a language dropdown with + English alongside. + +## Provenance + +Field list and quoted text from Exchange Resources Style Guide v1.1.0, §Before +You Upload and §Uploading to the Exchange. Export-drops-README is reasoning from +how Ignition serialises resources, not an observed export — verify it. diff --git a/exchangeResources/exchange_lint.py b/exchangeResources/exchange_lint.py new file mode 100755 index 0000000..d5daa60 --- /dev/null +++ b/exchangeResources/exchange_lint.py @@ -0,0 +1,1243 @@ +#!/usr/bin/env python3 +"""exchange_lint.py — conformance checker for the Ignition Exchange Resources Style Guide. + +Tests whether an Ignition project on disk (8.1 project folder layout) follows the +naming, structure, and storage conventions in "Exchange Resources Style Guide" +v1.1.0, and whether it is ready to upload to the Exchange. + +Every finding carries a rule id (E-...) so it can be traced back to a section of +the guide; run with --list-rules to print the full table. + +Severity follows the guide's own language: + FAIL the guide says "must", or the rule protects importability into someone + else's project (resource namespacing, session/client-tag storage). + warn a naming convention -- the guide "encourages" these but states they are + not required. --strict promotes every warning to a failure. + +Targets: a project folder, any folder containing projects (a gateway's +projects/ directory, an unpacked gateway backup, a source repo), or a +project-export .zip. The resource name is inferred from the namespace the +project actually uses, so no per-project configuration is needed. + +Usage: + python3 exchange_lint.py # scan the current directory + python3 exchange_lint.py /path/to/MyProject + python3 exchange_lint.py MyResource.zip --strict + python3 exchange_lint.py /gateway/projects # every project found + python3 exchange_lint.py --list-rules + +Exit status: 0 clean, 1 findings, 2 bad invocation. +Stdlib only; no third-party dependencies. +""" +import argparse +import ast +import io +import json +import os +import re +import sys +import tokenize + +PERSPECTIVE = "com.inductiveautomation.perspective" +VISION = "com.inductiveautomation.vision" + +# ---------------------------------------------------------------- rule table + +RULES = [ + # id, section, description + ("E-PROJ-NAME", "Projects", "project name lowercase, dashes, URL-friendly, no version suffix"), + ("E-PROJ-TITLE", "Projects", "project title human-friendly Title Case"), + ("E-PROJ-DESC", "Upload", "project description filled in (feeds the Exchange overview)"), + ("E-NS-ROOT", "Resource Structure", "every resource under Exchange// or exchange//"), + ("E-NS-CASE", "Resource Structure", "correct case of the namespace root for the resource type"), + ("E-NS-NAME", "Resource Structure", "second path segment is the resource name"), + ("E-VIEW-NAME", "Perspective", "view names Title Case or PascalCase"), + ("E-COMP-NAME", "Perspective", "component names PascalCase"), + ("E-PROP-NAME", "Perspective", "custom/param property names camelCase"), + ("E-PAGE-NAME", "Perspective", "page names lowercase with dashes"), + ("E-PAGE-ROOT", "Perspective", "page '/' claims the root URL of any importing project"), + ("E-STYLE-NAME", "Perspective", "style class names lowercase with dashes"), + ("E-STYLE-HIER", "Perspective", "style class placement mirrors the views that use it"), + ("E-MSG-NAME", "Perspective", "message handlers namespaced exchange.resourceName.handlerName"), + ("E-STORE-SESS", "Variable Storage", "no Perspective session custom properties (blocks clean import)"), + ("E-STORE-CTAG", "Variable Storage", "no Vision client tags (blocks clean import)"), + ("E-STORE-GLOB", "Variable Storage", "getGlobals() keyed ['exchange']['resourceName']"), + ("E-TAG-NAME", "Tags", "tag folders/names PascalCase or Title Case under Exchange/"), + ("E-VIS-NAME", "Vision", "window/template names Title Case or PascalCase"), + ("E-PY-TABS", "Code", "Python indented with tabs, not spaces"), + ("E-PY-LIB", "Code", "project library packages lowercase, single words"), + ("E-PY-FUNC", "Code", "function names camelCase"), + ("E-PY-CLASS", "Code", "class names PascalCase"), + ("E-PY-VAR", "Code", "variable names camelCase"), + ("E-PY-DOC", "Code", "public functions/classes carry a docstring"), + ("E-PY-DOCFMT", "Code", "docstring summary ends with . ? or !"), + ("E-PY-COMMENT", "Code", "comments start with '# ' (hash then space)"), + ("E-PY-PARSE", "Code", "source parses (checks below it are skipped otherwise)"), + ("E-LOG-NAME", "Loggers", "logger names exchange.resourceName[...].PascalCaseName"), + ("E-DB-TABLE", "Database", "table names ex__snake_case"), + ("E-DB-COL", "Database", "column names snake_case"), + ("E-DB-ID", "Database", "every table has an 'id' primary key"), + ("E-DB-FK", "Database", "foreign key columns named _id"), + ("E-NQ-NAME", "Named Queries", "named query names Title Case or PascalCase"), + ("E-NQ-PARAM", "Named Queries", "named query parameters camelCase"), + ("E-WEB-NAME", "WebDev", "WebDev sources lowercase with dashes"), + ("E-I18N-KEY", "Translation", "translation keys 'word' or '_translation_phrase'"), + ("E-UP-DOCS", "Upload", "installation/README documentation present in the resource"), +] +RULE_SECTIONS = {r[0]: r[1] for r in RULES} +VALID_RULES = set(RULE_SECTIONS) + +# Resource types and the case their namespace root uses, per the guide's +# Project Browser screenshots (uppercase Exchange/ for designer resources that +# are themselves capitalised; lowercase exchange/ for URL- and code-facing ones). +UPPER = "Exchange" +LOWER = "exchange" + +# ------------------------------------------------------------ name predicates + +RX_PASCAL = re.compile(r"^[A-Z][A-Za-z0-9]*$") +RX_CAMEL = re.compile(r"^[a-z][A-Za-z0-9]*$") +RX_KEBAB = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +RX_SNAKE = re.compile(r"^[a-z][a-z0-9_]*$") +RX_CONST = re.compile(r"^[A-Z][A-Z0-9_]*$") +RX_PROJECT = re.compile(r"^[a-z0-9]+([-_][a-z0-9]+)*$") +RX_VERSION_SUFFIX = re.compile(r"[-_. ]v?\d+([._]\d+)*$", re.IGNORECASE) +# Title Case tolerates lowercase joining words in non-initial position. +SMALL_WORDS = {"a", "an", "and", "as", "at", "by", "for", "in", "of", "on", + "or", "the", "to", "vs", "with"} + + +def is_pascal(s): + return bool(RX_PASCAL.match(s)) + + +def is_camel(s): + return bool(RX_CAMEL.match(s)) + + +def is_kebab(s): + return bool(RX_KEBAB.match(s)) + + +def is_title(s): + words = s.split(" ") + if len(words) < 2 or any(not w for w in words): + return False + for i, w in enumerate(words): + if RX_PASCAL.match(w): + continue + if i > 0 and w.lower() in SMALL_WORDS: + continue + return False + return True + + +def is_title_or_pascal(s): + """The guide lets top level resources be either Title Case or PascalCase.""" + return is_pascal(s) or is_title(s) + + +def norm(s): + """Fold a name to bare lowercase alphanumerics for cross-style comparison.""" + return re.sub(r"[^a-z0-9]", "", (s or "").lower()) + + +def pascalize(s): + return "".join(p[:1].upper() + p[1:] for p in re.split(r"[-_ ]+", s or "") if p) + + +def kebabize(s): + s = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", s or "") + return re.sub(r"[-_ ]+", "-", s).lower() + + +# ------------------------------------------------------------------- findings + +class Report(object): + def __init__(self, ignore=None, strict=False): + self.findings = [] # (severity, rule, where, message) + self.ignore = ignore or set() + self.strict = strict + self.checked = set() + + def _add(self, sev, rule, where, msg): + self.checked.add(rule) + if rule in self.ignore: + return + if sev == "warn" and self.strict: + sev = "FAIL" + self.findings.append((sev, rule, where, msg)) + + def fail(self, rule, where, msg): + self._add("FAIL", rule, where, msg) + + def warn(self, rule, where, msg): + self._add("warn", rule, where, msg) + + def touch(self, *rules): + """Record that a rule ran even though it produced no finding.""" + self.checked.update(rules) + + @property + def fails(self): + return [f for f in self.findings if f[0] == "FAIL"] + + @property + def warns(self): + return [f for f in self.findings if f[0] == "warn"] + + +# --------------------------------------------------------------- file helpers + +def load_json(rep, path, where): + try: + with open(path) as f: + return json.load(f) + except Exception as e: + rep.fail("E-PY-PARSE", where, "invalid JSON: %s" % e) + return None + + +def read_text(path): + with open(path, "r", encoding="utf-8", errors="replace") as f: + return f.read() + + +def find_payload_dirs(root, payload): + """Yield path-segment tuples for every directory under root holding payload.""" + out = [] + if not os.path.isdir(root): + return out + for dirpath, _dirs, files in os.walk(root): + if payload in files: + rel = os.path.relpath(dirpath, root) + if rel != ".": + out.append(tuple(rel.split(os.sep))) + return sorted(out) + + +def find_resource_dirs(root): + """Yield path-segment tuples for every leaf directory holding a resource.json.""" + out = [] + if not os.path.isdir(root): + return out + for dirpath, _dirs, files in os.walk(root): + if "resource.json" in files: + rel = os.path.relpath(dirpath, root) + if rel != ".": + out.append(tuple(rel.split(os.sep))) + return sorted(out) + + +def walk_components(node, visit, path="root"): + """Depth-first over a Perspective view component tree; visit(node, path).""" + if not isinstance(node, dict): + return + visit(node, path) + for i, child in enumerate(node.get("children") or []): + name = "?" + if isinstance(child, dict): + name = (child.get("meta") or {}).get("name", "?") + walk_components(child, visit, "%s/%s[%d]" % (path, name, i)) + + +# ------------------------------------------------------------ namespace check + +def check_namespace(rep, kind, parts, where, want_case, resource, name_style): + """Check //... on one resource path. + + parts path segments below the resource-type root + want_case UPPER or LOWER -- the expected spelling of the first segment + name_style "title-pascal" or "kebab" -- expected style of the resource segment + Returns the segments whose names still need style checking: those below + the namespace when it is correct, or below the wrong root when it is not. + """ + if len(parts) < 2: + rep.fail("E-NS-ROOT", where, + "%s is not inside a %s// folder (imports will collide " + "with the target project)" % (kind, want_case)) + return list(parts) + root, name = parts[0], parts[1] + if norm(root) != "exchange": + rep.fail("E-NS-ROOT", where, + "%s namespace root is %r, expected %r" % (kind, root, want_case)) + # Keep checking the names below the (wrong) root so a project that has + # not been namespaced yet still gets its naming findings. + return list(parts[1:]) + rep.touch("E-NS-ROOT") + if root != want_case: + rep.warn("E-NS-CASE", where, + "namespace root %r should be %r for %s" % (root, want_case, kind)) + else: + rep.touch("E-NS-CASE") + if resource and norm(name) != norm(resource): + rep.warn("E-NS-NAME", where, + "resource folder %r does not match resource name %r" % (name, resource)) + else: + rep.touch("E-NS-NAME") + if name_style == "kebab" and not is_kebab(name): + rep.warn("E-NS-NAME", where, + "resource folder %r should be lowercase-with-dashes" % name) + elif name_style == "title-pascal" and not is_title_or_pascal(name): + rep.warn("E-NS-NAME", where, + "resource folder %r should be Title Case or PascalCase" % name) + elif name_style == "camel" and not is_camel(name): + rep.warn("E-NS-NAME", where, + "resource folder %r should be lowercase or camelCase " + "(exchange.resourceName.scriptName)" % name) + return parts[2:] + + +# ------------------------------------------------------------------- sections + +def check_project(rep, proj_dir, proj_name): + """Projects: name, title, description.""" + where = "project.json" + if not RX_PROJECT.match(proj_name): + rep.fail("E-PROJ-NAME", where, + "project name %r must be lowercase letters, numbers, hyphens and " + "underscores only" % proj_name) + elif RX_VERSION_SUFFIX.search(proj_name): + rep.warn("E-PROJ-NAME", where, + "project name %r looks version-suffixed; the Exchange manages " + "versions on upload" % proj_name) + else: + rep.touch("E-PROJ-NAME") + + pj = os.path.join(proj_dir, "project.json") + if not os.path.exists(pj): + rep.warn("E-PROJ-TITLE", where, "no project.json found") + return + meta = load_json(rep, pj, where) or {} + title = (meta.get("title") or "").strip() + if not title: + rep.warn("E-PROJ-TITLE", where, "project title is empty") + elif not (is_title(title) or is_pascal(title)): + rep.warn("E-PROJ-TITLE", where, + "project title %r should be human-friendly Title Case" % title) + else: + rep.touch("E-PROJ-TITLE") + if not (meta.get("description") or "").strip(): + rep.warn("E-PROJ-DESC", where, + "project description is empty; the Exchange builds the resource " + "readme from it") + else: + rep.touch("E-PROJ-DESC") + + +def check_perspective(rep, proj_dir, resource): + """Perspective: views, components, properties, pages, style classes.""" + views_root = os.path.join(proj_dir, PERSPECTIVE, "views") + styles_root = os.path.join(proj_dir, PERSPECTIVE, "style-classes") + + view_paths = find_payload_dirs(views_root, "view.json") + style_paths = find_payload_dirs(styles_root, "style.json") + + # ---- views ----------------------------------------------------------- + for parts in view_paths: + where = "views/" + "/".join(parts) + tail = check_namespace(rep, "view", parts, where, UPPER, resource, "title-pascal") + for seg in (tail if tail is not None else []): + if not is_title_or_pascal(seg): + rep.warn("E-VIEW-NAME", where, + "view path segment %r should be Title Case or PascalCase" % seg) + else: + rep.touch("E-VIEW-NAME") + check_view_file(rep, os.path.join(views_root, *parts), where, resource) + + # ---- style classes --------------------------------------------------- + for parts in style_paths: + where = "style-classes/" + "/".join(parts) + tail = check_namespace(rep, "style class", parts, where, LOWER, resource, "kebab") + for seg in (tail if tail is not None else []): + if not is_kebab(seg): + rep.warn("E-STYLE-NAME", where, + "style class segment %r should be lowercase-with-dashes" % seg) + else: + rep.touch("E-STYLE-NAME") + + check_style_hierarchy(rep, views_root, view_paths, style_paths) + + # ---- pages ----------------------------------------------------------- + pc_path = os.path.join(proj_dir, PERSPECTIVE, "page-config", "config.json") + if os.path.exists(pc_path): + pc = load_json(rep, pc_path, "page-config/config.json") or {} + for url in (pc.get("pages") or {}): + where = "page-config/config.json" + if url == "/": + rep.warn("E-PAGE-ROOT", where, + "page '/' takes over the root URL of any project this " + "resource is imported into") + continue + rep.touch("E-PAGE-ROOT") + for seg in [s for s in url.split("/") if s]: + if seg.startswith(":"): # path parameter + continue + if not is_kebab(seg): + rep.warn("E-PAGE-NAME", where, + "page url segment %r should be lowercase-with-dashes" % seg) + else: + rep.touch("E-PAGE-NAME") + + # ---- session properties --------------------------------------------- + sp_path = os.path.join(proj_dir, PERSPECTIVE, "session-props", "props.json") + if os.path.exists(sp_path): + sp = load_json(rep, sp_path, "session-props/props.json") or {} + custom = sp.get("custom") or {} + leaves = _leaf_paths(custom) + if leaves: + rep.fail("E-STORE-SESS", "session-props/props.json", + "%d custom session propert%s (%s%s) -- the guide directs " + "resources to use view properties and system.util.getGlobals() " + "instead, so importing does not merge into the host project's " + "session props" + % (len(leaves), "y" if len(leaves) == 1 else "ies", + ", ".join(leaves[:3]), ", ..." if len(leaves) > 3 else "")) + else: + rep.touch("E-STORE-SESS") + + +def _leaf_paths(node, prefix=""): + """Flatten a nested property dict to dotted leaf paths.""" + out = [] + if isinstance(node, dict) and node: + for k in sorted(node): + out.extend(_leaf_paths(node[k], "%s.%s" % (prefix, k) if prefix else k)) + elif prefix: + out.append(prefix) + return out + + +def check_view_file(rep, view_dir, where, resource): + """Component names, custom/param property names, message handlers, globals.""" + vfile = os.path.join(view_dir, "view.json") + view = load_json(rep, vfile, where) + if view is None: + return + raw = read_text(vfile) + + for group in ("params", "custom"): + for key in (view.get(group) or {}): + if not is_camel(key): + rep.warn("E-PROP-NAME", where, + "view %s property %r should be camelCase" % (group, key)) + else: + rep.touch("E-PROP-NAME") + for key in (view.get("propConfig") or {}): + if key.startswith(("params.", "custom.")): + leaf = key.split(".", 1)[1].split(".")[0].split("[")[0] + if leaf and not is_camel(leaf): + rep.warn("E-PROP-NAME", where, + "view property %r should be camelCase" % key) + else: + rep.touch("E-PROP-NAME") + + def visit(node, cpath): + name = (node.get("meta") or {}).get("name") + if cpath == "root" or not name: + return + if not is_pascal(name): + rep.warn("E-COMP-NAME", where, "component %r should be PascalCase" % name) + else: + rep.touch("E-COMP-NAME") + for key in (node.get("custom") or {}): + if not is_camel(key): + rep.warn("E-PROP-NAME", where, + "custom property %r on %r should be camelCase" % (key, name)) + else: + rep.touch("E-PROP-NAME") + walk_components(view.get("root") or {}, visit) + + check_message_names(rep, raw, where, resource) + check_globals(rep, raw, where, resource) + + +def check_message_names(rep, text, where, resource): + """Message handler / sendMessage types namespaced to the resource.""" + seen = set() + for rx in (r'"messageType"\s*:\s*"([^"]+)"', + r'sendMessage\(\s*[\'"]([^\'"]+)[\'"]', + r'messageType\s*=\s*[\'"]([^\'"]+)[\'"]'): + for m in re.finditer(rx, text): + seen.add(m.group(1)) + for mt in sorted(seen): + parts = mt.split(".") + if len(parts) >= 3 and parts[0] == "exchange" and norm(parts[1]) == norm(resource): + rep.touch("E-MSG-NAME") + continue + rep.warn("E-MSG-NAME", where, + "message type %r should be exchange.%s.handlerName to avoid " + "collisions" % (mt, resource[:1].lower() + resource[1:])) + + +def check_globals(rep, text, where, resource): + """getGlobals() must be keyed ['exchange']['resourceName'].""" + for m in re.finditer(r"getGlobals\(\)\s*((?:\[\s*[\'\"][^\'\"]+[\'\"]\s*\])*)", text): + keys = re.findall(r"[\'\"]([^\'\"]+)[\'\"]", m.group(1)) + if len(keys) >= 2 and keys[0] == "exchange" and norm(keys[1]) == norm(resource): + rep.touch("E-STORE-GLOB") + else: + rep.warn("E-STORE-GLOB", where, + "getGlobals()%s should be keyed ['exchange']['%s']" + % ("".join("[%r]" % k for k in keys) or "[...]", + resource[:1].lower() + resource[1:])) + + +def check_style_hierarchy(rep, views_root, view_paths, style_paths): + """A class used by exactly one view belongs in a folder named for that view.""" + if not style_paths: + return + known = {"/".join(p) for p in style_paths} + users = {} + for parts in view_paths: + vfile = os.path.join(views_root, *parts, "view.json") + if not os.path.exists(vfile): + continue + view = load_json(rep, vfile, "views/" + "/".join(parts)) + if view is None: + continue + view_name = parts[-1] + + def visit(node, _cpath): + classes = ((node.get("props") or {}).get("style") or {}).get("classes") + if isinstance(classes, str): + for cls in classes.split(): + if cls in known: + users.setdefault(cls, set()).add(view_name) + walk_components(view.get("root") or {}, visit) + + for cls, vnames in sorted(users.items()): + segs = cls.split("/") + view_segs = {norm(s) for s in segs} + if len(vnames) == 1: + vn = next(iter(vnames)) + if norm(vn) not in view_segs: + rep.warn("E-STYLE-HIER", "style-classes/" + cls, + "used only by view %r; place it in a folder named for that " + "view" % vn) + else: + rep.touch("E-STYLE-HIER") + else: + shared = view_segs & {norm(v) for v in vnames} + if shared: + rep.warn("E-STYLE-HIER", "style-classes/" + cls, + "used by %d views (%s) but nested under a view-specific " + "folder; move it to the topmost folder covering them all" + % (len(vnames), ", ".join(sorted(vnames)[:3]))) + else: + rep.touch("E-STYLE-HIER") + + +def check_vision(rep, proj_dir, resource): + """Vision: windows, templates, and the client-tag prohibition.""" + vis = os.path.join(proj_dir, VISION) + if not os.path.isdir(vis): + return + ctags = os.path.join(vis, "client-tags") + if os.path.isdir(ctags) and os.listdir(ctags): + rep.fail("E-STORE-CTAG", "%s/client-tags" % VISION, + "Vision client tags present -- the guide directs resources away " + "from client tags so they import cleanly into another project") + else: + rep.touch("E-STORE-CTAG") + + for sub, kind in (("windows", "window"), ("templates", "template")): + root = os.path.join(vis, sub) + for parts in find_resource_dirs(root): + where = "%s/%s/%s" % (VISION, sub, "/".join(parts)) + tail = check_namespace(rep, kind, parts, where, UPPER, resource, "title-pascal") + for seg in (tail if tail is not None else []): + if not is_title_or_pascal(seg): + rep.warn("E-VIS-NAME", where, + "%s name %r should be Title Case or PascalCase" % (kind, seg)) + else: + rep.touch("E-VIS-NAME") + + +def check_scripts(rep, proj_dir, resource): + """Code: library layout, indentation, names, docstrings, comments, loggers.""" + sp_root = os.path.join(proj_dir, "ignition", "script-python") + if not os.path.isdir(sp_root): + return + for dirpath, _dirs, files in os.walk(sp_root): + if "code.py" not in files: + continue + parts = tuple(os.path.relpath(dirpath, sp_root).split(os.sep)) + where = "script-python/" + "/".join(parts) + if norm(parts[0]) == "exchange": + tail = check_namespace(rep, "script package", parts, where, LOWER, + resource, "camel") + tail = tail if tail is not None else [] + else: + rep.fail("E-NS-ROOT", where, + "project library package %r is not under exchange//; " + "importing would land scripts in the host project's namespace" + % parts[0]) + tail = list(parts[1:]) + for seg in tail: + if not is_camel(seg): + rep.warn("E-PY-LIB", where, + "library package/script %r should be lowercase, a single " + "word where possible, camelCase otherwise" % seg) + else: + rep.touch("E-PY-LIB") + check_python_file(rep, os.path.join(dirpath, "code.py"), where + "/code.py", + resource) + + +def check_python_file(rep, path, where, resource): + src = read_text(path) + check_py_indentation(rep, src, where) + check_py_comments(rep, src, where) + check_globals(rep, src, where, resource) + check_message_names(rep, src, where, resource) + check_loggers(rep, src, where, resource) + try: + tree = ast.parse(src) + except SyntaxError as e: + rep.warn("E-PY-PARSE", where, + "could not parse (line %s): %s -- name and docstring checks skipped" + % (e.lineno, e.msg)) + return + rep.touch("E-PY-PARSE") + check_py_names(rep, tree, where) + + +def check_py_indentation(rep, src, where): + """Tabs, not spaces. Uses the tokenizer so continuation lines are not counted.""" + space_lines, tab_lines = [], [] + try: + for tok in tokenize.generate_tokens(io.StringIO(src).readline): + if tok.type == tokenize.INDENT: + (tab_lines if "\t" in tok.string else space_lines).append(tok.start[0]) + except (tokenize.TokenError, IndentationError, SyntaxError): + for i, line in enumerate(src.splitlines(), 1): + if line.startswith("\t"): + tab_lines.append(i) + elif line.startswith(" "): + space_lines.append(i) + if space_lines: + preview = ", ".join(str(n) for n in space_lines[:5]) + rep.warn("E-PY-TABS", where, + "%d space-indented block%s (line%s %s%s); Ignition's editor inserts " + "tabs" % (len(space_lines), "" if len(space_lines) == 1 else "s", + "" if len(space_lines) == 1 else "s", preview, + ", ..." if len(space_lines) > 5 else "")) + elif tab_lines: + rep.touch("E-PY-TABS") + + +def check_py_comments(rep, src, where): + """Comments start with '#' then a space.""" + bad = [] + try: + for tok in tokenize.generate_tokens(io.StringIO(src).readline): + if tok.type != tokenize.COMMENT: + continue + body = tok.string + if body in ("#", "#!"): + continue + if body.startswith("#!") and tok.start[0] == 1: + continue + if re.match(r"^#[-=#*]{2,}", body): # separator banners + continue + if not body.startswith("# "): + bad.append(tok.start[0]) + except (tokenize.TokenError, IndentationError, SyntaxError): + return + if bad: + rep.warn("E-PY-COMMENT", where, + "%d comment%s missing the space after '#' (line%s %s%s)" + % (len(bad), "" if len(bad) == 1 else "s", + "" if len(bad) == 1 else "s", + ", ".join(str(n) for n in bad[:5]), + ", ..." if len(bad) > 5 else "")) + else: + rep.touch("E-PY-COMMENT") + + +def check_py_names(rep, tree, where): + """Function/class/variable names and docstrings.""" + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + if not is_pascal(node.name.lstrip("_")): + rep.warn("E-PY-CLASS", where, + "class %r (line %d) should be PascalCase" % (node.name, node.lineno)) + else: + rep.touch("E-PY-CLASS") + check_docstring(rep, node, where, "class") + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + bare = node.name.strip("_") + dunder = node.name.startswith("__") and node.name.endswith("__") + if not dunder and bare and not is_camel(bare): + rep.warn("E-PY-FUNC", where, + "function %r (line %d) should be camelCase (the guide " + "diverges from PEP-8 here)" % (node.name, node.lineno)) + else: + rep.touch("E-PY-FUNC") + if not dunder: + check_docstring(rep, node, where, "function") + for arg in _all_args(node): + _check_var(rep, arg.arg, arg.lineno, where, "argument") + elif isinstance(node, ast.Assign): + for tgt in node.targets: + for name, lineno in _assigned_names(tgt): + _check_var(rep, name, lineno, where, "variable") + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + _check_var(rep, node.target.id, node.lineno, where, "variable") + + +def _all_args(node): + a = node.args + out = list(getattr(a, "posonlyargs", [])) + list(a.args) + list(a.kwonlyargs) + if a.vararg: + out.append(a.vararg) + if a.kwarg: + out.append(a.kwarg) + return out + + +def _assigned_names(tgt): + if isinstance(tgt, ast.Name): + yield tgt.id, tgt.lineno + elif isinstance(tgt, (ast.Tuple, ast.List)): + for el in tgt.elts: + for pair in _assigned_names(el): + yield pair + + +def _check_var(rep, name, lineno, where, kind): + bare = name.strip("_") + if not bare or bare == "self" or bare == "cls": + return + # Module constants fall back to PEP-8, which the guide names as the default + # for anything it does not cover. + if RX_CONST.match(bare): + rep.touch("E-PY-VAR") + return + if is_camel(bare): + rep.touch("E-PY-VAR") + return + rep.warn("E-PY-VAR", where, + "%s %r (line %d) should be camelCase" % (kind, name, lineno)) + + +def check_docstring(rep, node, where, kind): + doc = ast.get_docstring(node, clean=False) + if node.name.startswith("_"): + return + if not doc or not doc.strip(): + rep.warn("E-PY-DOC", where, + "%s %r (line %d) has no docstring; Ignition 8.1.32+ surfaces these " + "in autocomplete" % (kind, node.name, node.lineno)) + return + rep.touch("E-PY-DOC") + summary = doc.strip().splitlines()[0].strip() + if summary and summary[-1] not in ".?!": + rep.warn("E-PY-DOCFMT", where, + "%s %r (line %d) docstring summary should end with . ? or !" + % (kind, node.name, node.lineno)) + else: + rep.touch("E-PY-DOCFMT") + + +def check_loggers(rep, src, where, resource): + """exchange.resourceName[.path].PascalCaseName""" + want = resource[:1].lower() + resource[1:] + for m in re.finditer(r"getLogger\(\s*[\'\"]([^\'\"]+)[\'\"]", src): + name = m.group(1) + parts = name.split(".") + ok = (len(parts) >= 3 and parts[0] == "exchange" + and norm(parts[1]) == norm(resource) + and all(is_camel(p) for p in parts[2:-1]) + and is_pascal(parts[-1])) + if ok: + rep.touch("E-LOG-NAME") + else: + rep.warn("E-LOG-NAME", where, + "logger %r should be exchange.%s[.path].LoggerName with a " + "PascalCase final element" % (name, want)) + + +def check_named_queries(rep, proj_dir, resource): + root = os.path.join(proj_dir, "ignition", "named-query") + for parts in find_resource_dirs(root): + where = "named-query/" + "/".join(parts) + tail = check_namespace(rep, "named query", parts, where, UPPER, resource, + "title-pascal") + for seg in (tail if tail is not None else []): + if not is_title_or_pascal(seg): + rep.warn("E-NQ-NAME", where, + "named query %r should be Title Case or PascalCase" % seg) + else: + rep.touch("E-NQ-NAME") + qdir = os.path.join(root, *parts) + for fn in sorted(os.listdir(qdir)): + if not fn.endswith(".json") or fn == "resource.json": + continue + data = load_json(rep, os.path.join(qdir, fn), where) + for p in ((data or {}).get("parameters") or []): + pname = p.get("name") if isinstance(p, dict) else p + if isinstance(pname, str) and not is_camel(pname): + rep.warn("E-NQ-PARAM", where, + "query parameter %r should be camelCase" % pname) + elif isinstance(pname, str): + rep.touch("E-NQ-PARAM") + + +def check_other_resources(rep, proj_dir, resource): + """Alarm pipelines, SFCs, transaction groups, reports, WebDev, translations.""" + ign = os.path.join(proj_dir, "ignition") + upper_kinds = { + "alarm-pipeline": "alarm pipeline", "alarm-pipelines": "alarm pipeline", + "sfc": "SFC", "sfcs": "SFC", + "transaction-group": "transaction group", "transaction-groups": "transaction group", + "report": "report", "reports": "report", + } + if os.path.isdir(ign): + for entry in sorted(os.listdir(ign)): + kind = upper_kinds.get(entry) + if not kind: + continue + for parts in find_resource_dirs(os.path.join(ign, entry)): + where = "%s/%s" % (entry, "/".join(parts)) + tail = check_namespace(rep, kind, parts, where, UPPER, resource, + "title-pascal") + for seg in (tail if tail is not None else []): + if not is_title_or_pascal(seg): + rep.warn("E-VIEW-NAME", where, + "%s name %r should be Title Case or PascalCase" + % (kind, seg)) + + for webroot in (os.path.join(proj_dir, "com.inductiveautomation.webdev"), + os.path.join(ign, "webdev")): + if not os.path.isdir(webroot): + continue + base = os.path.join(webroot, "resources") + base = base if os.path.isdir(base) else webroot + for parts in find_resource_dirs(base): + where = "webdev/" + "/".join(parts) + tail = check_namespace(rep, "WebDev source", parts, where, LOWER, + resource, "kebab") + for seg in (tail if tail is not None else []): + stem = seg.rsplit(".", 1)[0] + if not is_kebab(stem): + rep.warn("E-WEB-NAME", where, + "WebDev source %r should be lowercase-with-dashes" % seg) + else: + rep.touch("E-WEB-NAME") + + check_translations(rep, proj_dir) + + +def check_translations(rep, proj_dir): + for dirpath, _dirs, files in os.walk(proj_dir): + for fn in files: + if "locale" not in fn and "translation" not in fn.lower(): + continue + if not fn.endswith(".json"): + continue + where = os.path.relpath(os.path.join(dirpath, fn), proj_dir) + data = load_json(rep, os.path.join(dirpath, fn), where) + keys = list(data) if isinstance(data, dict) else [] + for key in keys: + if not isinstance(key, str): + continue + ok = (re.match(r"^[A-Za-z0-9]+$", key) + or re.match(r"^_[a-z0-9]+(_[a-z0-9]+)*$", key)) + if ok: + rep.touch("E-I18N-KEY") + else: + rep.warn("E-I18N-KEY", where, + "translation key %r should be a single word, or " + "_underscore_separated_phrase" % key) + + +def check_tags(rep, tag_files, resource): + """Tag export JSON: Exchange//... with PascalCase or Title Case names.""" + for tf in tag_files: + where = os.path.basename(tf) + data = load_json(rep, tf, where) + if data is None: + continue + + def walk(node, trail): + if not isinstance(node, dict): + return + name = node.get("name") + here = trail + [name] if name else trail + if name: + if not is_title_or_pascal(name): + rep.warn("E-TAG-NAME", where, + "tag/folder %r should be PascalCase or Title Case" + % "/".join(here)) + else: + rep.touch("E-TAG-NAME") + for child in (node.get("tags") or []): + walk(child, here) + + roots = data.get("tags") or [] + top = [t.get("name") for t in roots if isinstance(t, dict)] + if not any(norm(n) == "exchange" for n in top if n): + rep.fail("E-NS-ROOT", where, + "tag export root is %s; tags must live under Exchange/%s/" + % (top or "(empty)", resource)) + for t in roots: + walk(t, []) + + +def check_database(rep, sql_files, resource): + """CREATE TABLE statements: ex_ prefix, snake_case, id PK, *_id foreign keys.""" + abbrev_hint = "".join(w[0] for w in re.findall(r"[A-Z][a-z]*", resource)).lower() + for sf in sql_files: + where = os.path.basename(sf) + sql = read_text(sf) + for m in re.finditer( + r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"\[]?([A-Za-z0-9_.]+)[`\"\]]?\s*\((.*?)\)\s*;", + sql, re.IGNORECASE | re.DOTALL): + table = m.group(1).split(".")[-1] + body = m.group(2) + if not RX_SNAKE.match(table): + rep.warn("E-DB-TABLE", where, + "table %r should be snake_case" % table) + elif not table.startswith("ex_"): + rep.warn("E-DB-TABLE", where, + "table %r should start with 'ex_' (e.g. ex_%s_%s) to avoid " + "collisions in an existing schema" + % (table, abbrev_hint or "abbrev", table)) + else: + rep.touch("E-DB-TABLE") + + cols = [] + for line in body.splitlines(): + line = line.strip().rstrip(",") + cm = re.match(r"^[`\"\[]?([A-Za-z0-9_]+)[`\"\]]?\s+[A-Za-z]", line) + if not cm: + continue + kw = cm.group(1).upper() + if kw in ("PRIMARY", "FOREIGN", "UNIQUE", "KEY", "INDEX", "CONSTRAINT", "CHECK"): + continue + cols.append(cm.group(1)) + for col in cols: + if not RX_SNAKE.match(col): + rep.warn("E-DB-COL", where, + "column %s.%s should be snake_case" % (table, col)) + else: + rep.touch("E-DB-COL") + if "id" not in cols: + rep.warn("E-DB-ID", where, + "table %r has no 'id' column; the first auto-increment " + "primary key should be named 'id'" % table) + else: + rep.touch("E-DB-ID") + for fkm in re.finditer( + r"FOREIGN\s+KEY\s*\(\s*[`\"\[]?([A-Za-z0-9_]+)[`\"\]]?\s*\)\s*" + r"REFERENCES\s+[`\"\[]?([A-Za-z0-9_]+)", + body, re.IGNORECASE): + col, ref = fkm.group(1), fkm.group(2).split(".")[-1] + if col != "%s_id" % ref: + rep.warn("E-DB-FK", where, + "foreign key %s.%s references %s; name it %s_id" + % (table, col, ref, ref)) + else: + rep.touch("E-DB-FK") + + +def check_upload_readiness(rep, proj_dir, extra_roots=()): + """Documentation the Exchange upload form and the guide's checklist expect.""" + bases = [proj_dir] + [r for r in extra_roots if r] + for base in bases: + if not os.path.isdir(base): + continue + for fn in os.listdir(base): + if re.match(r"^(readme|install|installation|docs?|instructions)", + fn, re.IGNORECASE): + rep.touch("E-UP-DOCS") + return + rep.warn("E-UP-DOCS", "(resource root)", + "no README / installation document found; the Exchange upload form " + "asks for custom installation instructions") + + +# ------------------------------------------------------------------ discovery + +# Markers specific enough not to match a gateway's config/resources tree, which +# also carries com.inductiveautomation.* folders but is not a project. +PROJECT_MARKERS = ( + "project.json", + os.path.join(PERSPECTIVE, "views"), + os.path.join(PERSPECTIVE, "session-props"), + os.path.join(PERSPECTIVE, "page-config"), + os.path.join(VISION, "windows"), + os.path.join(VISION, "templates"), + os.path.join("ignition", "script-python"), +) +MAX_SCAN_DEPTH = 6 +SKIP_DIRS = {".git", ".svn", "__pycache__", "node_modules", ".resources", + ".venv", "venv", ".idea", ".vscode"} + + +def looks_like_project(d): + """True if d is the root of an Ignition 8.x project folder.""" + if not os.path.isdir(d): + return False + return any(os.path.exists(os.path.join(d, m)) for m in PROJECT_MARKERS) + + +def discover_projects(target): + """Return project directories under target. + + Accepts a project folder, any directory containing one or more projects + (a gateway's projects/ dir, an unpacked gateway backup, a repo), or a + project-export .zip. Nested projects are not searched once a project root + is found, so a projects/ dir yields its children, not their subfolders. + """ + target = os.path.abspath(target) + if os.path.isfile(target): + if target.lower().endswith(".zip"): + return discover_projects(unpack_zip(target)) + raise SystemExit("not a project folder or .zip export: %s" % target) + if not os.path.isdir(target): + raise SystemExit("no such path: %s" % target) + if looks_like_project(target): + return [target] + + found = [] + base_depth = target.rstrip(os.sep).count(os.sep) + for dirpath, dirs, _files in os.walk(target): + dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")] + if dirpath.count(os.sep) - base_depth >= MAX_SCAN_DEPTH: + dirs[:] = [] + continue + for d in list(dirs): + cand = os.path.join(dirpath, d) + if looks_like_project(cand): + found.append(cand) + dirs.remove(d) + return sorted(found) + + +def unpack_zip(path): + """Extract a project-export zip to a temp dir and return the project root.""" + import tempfile + import zipfile + dest = tempfile.mkdtemp(prefix="exchange_lint_") + with zipfile.ZipFile(path) as zf: + for member in zf.namelist(): + # refuse absolute paths and traversal in an untrusted archive + norm_m = os.path.normpath(member) + if os.path.isabs(norm_m) or norm_m.startswith(".."): + raise SystemExit("unsafe path in archive: %s" % member) + zf.extractall(dest) + entries = [os.path.join(dest, e) for e in os.listdir(dest)] + if len(entries) == 1 and os.path.isdir(entries[0]): + return entries[0] + return dest + + +def discover_aux(proj_dir, pattern_fn, limit=200): + """Collect auxiliary files (tag exports, .sql dumps) shipped beside a project.""" + out = [] + roots = [proj_dir, os.path.dirname(proj_dir.rstrip(os.sep))] + seen = set() + for base in roots: + if not os.path.isdir(base) or base in seen: + continue + seen.add(base) + for dirpath, dirs, files in os.walk(base): + dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")] + for fn in sorted(files): + p = os.path.join(dirpath, fn) + if p not in out and pattern_fn(p): + out.append(p) + if len(out) >= limit: + return out + return out + + +def is_tag_export(path): + if not path.lower().endswith(".json"): + return False + if os.path.basename(path) in ("project.json", "resource.json", "view.json", + "style.json", "props.json", "config.json"): + return False + try: + with open(path) as f: + head = f.read(4096) + except OSError: + return False + return '"tags"' in head and ('"tagType"' in head or '"valueSource"' in head + or '"tagGroup"' in head) + + +def is_sql_file(path): + return path.lower().endswith(".sql") + + +def infer_resource(proj_dir, proj_name): + """Best guess at the resource name from the view namespace, else the project.""" + for sub, payload in ((os.path.join(PERSPECTIVE, "views"), "view.json"), + (os.path.join(VISION, "windows"), "resource.json")): + paths = find_payload_dirs(os.path.join(proj_dir, sub), payload) + if not paths: + continue + tops = {} + for p in paths: + key = p[1] if len(p) > 1 and norm(p[0]) == "exchange" else p[0] + tops[key] = tops.get(key, 0) + 1 + return max(tops, key=lambda k: tops[k]) + return pascalize(proj_name) + + +# ---------------------------------------------------------------------- main + +def render(rep, proj_name, proj_dir, resource, as_json, show_header=True, + max_per_rule=8): + if as_json: + return { + "project": proj_name, + "path": proj_dir, + "resource": resource, + "failures": len(rep.fails), + "warnings": len(rep.warns), + "rules_exercised": sorted(rep.checked), + "findings": [{"severity": s, "rule": r, "section": RULE_SECTIONS.get(r, "?"), + "where": w, "message": m} for s, r, w, m in rep.findings], + } + if show_header: + print("Ignition Exchange Style Guide v1.1.0 — conformance report") + print("\nproject: %s resource: %s" % (proj_name, resource)) + print("path: %s" % proj_dir) + if not rep.findings: + print(" No findings.") + sections = [] + for _rid, sec, _desc in RULES: + if sec not in sections: + sections.append(sec) + for sec in sections: + rows = [f for f in rep.findings if RULE_SECTIONS.get(f[1]) == sec] + if not rows: + continue + print("\n %s" % sec) + rows.sort(key=lambda f: (f[0] != "FAIL", f[1], f[2])) + shown = {} + for sev, rule, where, msg in rows: + n = shown.get(rule, 0) + if max_per_rule and n >= max_per_rule: + shown[rule] = n + 1 + continue + shown[rule] = n + 1 + print(" %-4s %-13s %s: %s" % (sev, rule, where, msg)) + for rule, n in shown.items(): + if max_per_rule and n > max_per_rule: + print(" %-4s %-13s ... and %d more (-v for all)" + % ("", rule, n - max_per_rule)) + print("\n summary: %d failure(s), %d warning(s); %d of %d rules exercised" + % (len(rep.fails), len(rep.warns), len(rep.checked), len(RULES))) + return None + + +def lint_project(proj_dir, resource=None, tags=None, sql=None, ignore=None, + strict=False): + """Run every check against one project directory and return its Report.""" + proj_name = os.path.basename(proj_dir.rstrip(os.sep)) + resource = resource or infer_resource(proj_dir, proj_name) + tags = tags if tags is not None else discover_aux(proj_dir, is_tag_export) + sql = sql if sql is not None else discover_aux(proj_dir, is_sql_file) + + rep = Report(ignore=ignore, strict=strict) + check_project(rep, proj_dir, proj_name) + check_perspective(rep, proj_dir, resource) + check_vision(rep, proj_dir, resource) + check_scripts(rep, proj_dir, resource) + check_named_queries(rep, proj_dir, resource) + check_other_resources(rep, proj_dir, resource) + check_tags(rep, tags, resource) + check_database(rep, sql, resource) + check_upload_readiness(rep, proj_dir, + [os.path.dirname(proj_dir.rstrip(os.sep))]) + return rep, proj_name, resource + + +def main(argv=None): + ap = argparse.ArgumentParser( + description="Check an Ignition project against the Ignition Exchange " + "Resources Style Guide (v1.1.0).", + epilog="TARGET may be a project folder, a folder containing projects " + "(a gateway projects/ dir, an unpacked gateway backup, a repo), " + "or a project-export .zip. Defaults to the current directory.") + ap.add_argument("target", nargs="*", default=None, + help="project folder, containing folder, or .zip export") + ap.add_argument("--resource", help="Exchange resource name (default: inferred " + "from the resource namespace)") + ap.add_argument("--tags", nargs="*", default=None, + help="tag export .json files (default: auto-discovered)") + ap.add_argument("--sql", nargs="*", default=None, + help=".sql files to check (default: auto-discovered)") + ap.add_argument("--strict", action="store_true", help="treat warnings as failures") + ap.add_argument("--ignore", default="", help="comma-separated rule ids to suppress") + ap.add_argument("--json", action="store_true", dest="as_json", + help="machine-readable output") + ap.add_argument("--list-rules", action="store_true", + help="print the rule table and exit") + ap.add_argument("-v", "--verbose", action="store_true", + help="list every occurrence instead of collapsing per rule") + ap.add_argument("--max-per-rule", type=int, default=8, + help="occurrences shown per rule before collapsing (default 8)") + args = ap.parse_args(argv) + + if args.list_rules: + width = max(len(r[0]) for r in RULES) + for rid, sec, desc in RULES: + print("%-*s %-20s %s" % (width, rid, sec, desc)) + return 0 + + ignore = {r.strip() for r in args.ignore.split(",") if r.strip()} + unknown = ignore - VALID_RULES + if unknown: + print("unknown rule id(s) in --ignore: %s" % ", ".join(sorted(unknown)), + file=sys.stderr) + return 2 + + targets = args.target or [os.getcwd()] + projects = [] + try: + for t in targets: + for p in discover_projects(t): + if p not in projects: + projects.append(p) + except SystemExit as e: + print(e, file=sys.stderr) + return 2 + if not projects: + print("no Ignition project found under: %s" % ", ".join(targets), + file=sys.stderr) + return 2 + if args.resource and len(projects) > 1: + print("--resource applies to a single project; %d found" % len(projects), + file=sys.stderr) + return 2 + + results, total_fails = [], 0 + for i, proj_dir in enumerate(projects): + rep, proj_name, resource = lint_project( + proj_dir, resource=args.resource, tags=args.tags, sql=args.sql, + ignore=ignore, strict=args.strict) + total_fails += len(rep.fails) + payload = render(rep, proj_name, proj_dir, resource, args.as_json, + show_header=(i == 0), + max_per_rule=0 if args.verbose else args.max_per_rule) + if payload is not None: + results.append(payload) + + if args.as_json: + print(json.dumps({"projects": results, + "failures": total_fails}, indent=2)) + elif len(projects) > 1: + print("\n%d project(s) checked, %d total failure(s)" + % (len(projects), total_fails)) + return 1 if total_fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/exchangeResources/test_exchange_lint.py b/exchangeResources/test_exchange_lint.py new file mode 100644 index 0000000..4cf6c30 --- /dev/null +++ b/exchangeResources/test_exchange_lint.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""Self-test for exchange_lint.py. + +Builds two throwaway Ignition projects in a temp directory -- one that follows +the Exchange Resources Style Guide and one that breaks a known rule per check -- +then asserts the checker stays silent on the first and fires the expected rule +id on the second. This is what keeps the rules honest: a rule that cannot fail +and a rule that always fires both show up here. + +Run directly (python3 test_exchange_lint.py) or under pytest. +""" +import json +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import exchange_lint as el # noqa: E402 + +RESOURCE = "SampleResource" +PERSP = el.PERSPECTIVE + + +# ------------------------------------------------------------------ fixtures + +def write(path, text): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(text) + + +def write_json(path, obj): + write(path, json.dumps(obj, indent=2)) + + +RESOURCE_JSON = {"scope": "G", "version": 1, "restricted": False, + "overridable": True, "files": ["view.json"], "attributes": {}} + +CLEAN_CODE = '''"""Sample library module.""" + + +def formatCount(rawCount): + """Return rawCount rendered for display.""" + # pad small values so the column stays aligned + displayValue = "%d" % rawCount + return displayValue +''' + +DIRTY_CODE = '''def format_count(raw_count): + #no space after hash + display_value = "%d" % raw_count + return display_value +''' + + +def build_project(root, name, clean=True): + """Create a minimal Perspective project; clean=False breaks one rule per check.""" + proj = os.path.join(root, name) + ns_upper = "Exchange/%s" % RESOURCE if clean else "MyCompany" + ns_lower = "exchange/sample-resource" if clean else "MyCompany" + lib_pkg = "exchange/sampleresource" if clean else "MyCompany" + + write_json(os.path.join(proj, "project.json"), { + "title": "Sample Resource" if clean else "", + "description": "A sample Exchange resource." if clean else "", + "enabled": True, "inheritable": False, "parent": "", + }) + + view_dir = os.path.join(proj, PERSP, "views", *ns_upper.split("/"), "Dashboard") + write_json(os.path.join(view_dir, "resource.json"), RESOURCE_JSON) + write_json(os.path.join(view_dir, "view.json"), { + "params": {"startMs": 0} if clean else {"start_ms": 0}, + "custom": {}, + "root": { + "type": "ia.container.flex", + "meta": {"name": "root"}, + "props": {"style": {"classes": "%s/dashboard/card" % ns_lower + if clean else "%s/Card" % ns_lower}}, + "children": [{ + "type": "ia.display.label", + "meta": {"name": "TitleLabel" if clean else "titleLabel"}, + "custom": {"labelText": ""} if clean else {"label_text": ""}, + "props": {}, + "scripts": {"messageHandlers": [{ + "messageType": ("exchange.sampleResource.refresh" + if clean else "refresh")}]}, + }], + }, + }) + + style_dir = os.path.join(proj, PERSP, "style-classes", *ns_lower.split("/"), + *(("dashboard", "card") if clean else ("Card",))) + write_json(os.path.join(style_dir, "resource.json"), + dict(RESOURCE_JSON, files=["style.json"])) + write_json(os.path.join(style_dir, "style.json"), {"style": {}}) + + write_json(os.path.join(proj, PERSP, "page-config", "config.json"), + {"pages": {"/sample-resource" if clean else "/": { + "viewPath": "%s/Dashboard" % ns_upper}}, "sharedDocks": {}}) + + write_json(os.path.join(proj, PERSP, "session-props", "props.json"), + {"custom": {} if clean else {"MyCompany": {"selectedTab": 0}}, + "props": {}}) + + code_dir = os.path.join(proj, "ignition", "script-python", + *lib_pkg.split("/"), "fmt") + write_json(os.path.join(code_dir, "resource.json"), + dict(RESOURCE_JSON, scope="A", files=["code.py"])) + write(os.path.join(code_dir, "code.py"), CLEAN_CODE if clean else DIRTY_CODE) + + if clean: + write(os.path.join(proj, "README.md"), "# Sample Resource\n\nInstall steps.\n") + else: + vis_ctags = os.path.join(proj, el.VISION, "client-tags") + write_json(os.path.join(vis_ctags, "resource.json"), + dict(RESOURCE_JSON, files=["data.bin"])) + write(os.path.join(vis_ctags, "data.bin"), "x") + return proj + + +SQL_CLEAN = """ +CREATE TABLE ex_sr_contacts ( + id INT NOT NULL AUTO_INCREMENT, + contact_name VARCHAR(64), + PRIMARY KEY (id) +); +CREATE TABLE ex_sr_calls ( + id INT NOT NULL AUTO_INCREMENT, + ex_sr_contacts_id INT, + PRIMARY KEY (id), + FOREIGN KEY (ex_sr_contacts_id) REFERENCES ex_sr_contacts(id) +); +""" + +SQL_DIRTY = """ +CREATE TABLE Contacts ( + contactId INT NOT NULL AUTO_INCREMENT, + ContactName VARCHAR(64), + PRIMARY KEY (contactId) +); +CREATE TABLE ex_sr_calls ( + id INT NOT NULL AUTO_INCREMENT, + contact INT, + PRIMARY KEY (id), + FOREIGN KEY (contact) REFERENCES Contacts(contactId) +); +""" + +TAGS_CLEAN = {"tags": [{"name": "Exchange", "tagType": "Folder", "tags": [ + {"name": RESOURCE, "tagType": "Folder", "tags": [ + {"name": "AlarmCount", "tagType": "AtomicTag", "valueSource": "memory"}]}]}]} + +TAGS_DIRTY = {"tags": [{"name": "my_company", "tagType": "Folder", "tags": [ + {"name": "alarm_count", "tagType": "AtomicTag", "valueSource": "memory"}]}]} + + +# --------------------------------------------------------------------- tests + +class ExchangeLintTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.mkdtemp(prefix="exlint_test_") + cls.clean = build_project(cls.tmp, "sample-resource", clean=True) + cls.dirty = build_project(cls.tmp, "SampleResource_v2", clean=False) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmp, ignore_errors=True) + + def run_lint(self, proj, **kw): + kw.setdefault("tags", []) + kw.setdefault("sql", []) + kw.setdefault("resource", RESOURCE) + rep, _name, _res = el.lint_project(proj, **kw) + return rep + + def rules(self, rep): + return {f[1] for f in rep.findings} + + # -- the clean project must be silent --------------------------------- + + def test_clean_project_has_no_findings(self): + rep = self.run_lint(self.clean) + self.assertEqual(rep.findings, [], + "conforming project produced findings:\n" + + "\n".join("%s %s %s: %s" % f for f in rep.findings)) + + def test_clean_project_exits_zero(self): + rc = el.main([self.clean, "--resource", RESOURCE, "--tags", "--sql"]) + self.assertEqual(rc, 0) + + # -- the dirty project must fire each rule ---------------------------- + + def test_dirty_project_fires_expected_rules(self): + got = self.rules(self.run_lint(self.dirty)) + for rule in ("E-PROJ-NAME", "E-PROJ-TITLE", "E-PROJ-DESC", + "E-NS-ROOT", "E-COMP-NAME", "E-PROP-NAME", + "E-PAGE-ROOT", "E-STYLE-NAME", "E-MSG-NAME", + "E-STORE-SESS", "E-STORE-CTAG", + "E-PY-TABS", "E-PY-FUNC", "E-PY-VAR", "E-PY-DOC", + "E-PY-COMMENT", "E-UP-DOCS"): + self.assertIn(rule, got, "expected %s to fire" % rule) + + def test_dirty_project_exits_nonzero(self): + rc = el.main([self.dirty, "--resource", RESOURCE, "--tags", "--sql"]) + self.assertEqual(rc, 1) + + # -- severity model --------------------------------------------------- + + def test_namespace_and_storage_are_failures(self): + rep = self.run_lint(self.dirty) + fails = {f[1] for f in rep.fails} + for rule in ("E-NS-ROOT", "E-STORE-SESS", "E-STORE-CTAG", "E-PROJ-NAME"): + self.assertIn(rule, fails, "%s should be a FAIL, not a warning" % rule) + + def test_naming_rules_are_warnings_by_default(self): + rep = self.run_lint(self.dirty) + warns = {f[1] for f in rep.warns} + for rule in ("E-COMP-NAME", "E-PY-FUNC", "E-PY-TABS"): + self.assertIn(rule, warns, "%s should warn by default" % rule) + + def test_strict_promotes_warnings(self): + rep = self.run_lint(self.dirty, strict=True) + self.assertEqual(rep.warns, [], "--strict left warnings behind") + self.assertIn("E-COMP-NAME", {f[1] for f in rep.fails}) + + def test_ignore_suppresses_a_rule(self): + rep = self.run_lint(self.dirty, ignore={"E-COMP-NAME"}) + self.assertNotIn("E-COMP-NAME", self.rules(rep)) + + # -- database and tag checks ----------------------------------------- + + def test_sql_clean_and_dirty(self): + clean_sql = os.path.join(self.tmp, "clean.sql") + dirty_sql = os.path.join(self.tmp, "dirty.sql") + write(clean_sql, SQL_CLEAN) + write(dirty_sql, SQL_DIRTY) + + rep = el.Report() + el.check_database(rep, [clean_sql], RESOURCE) + self.assertEqual(rep.findings, [], + "conforming schema produced findings: %s" % rep.findings) + + rep = el.Report() + el.check_database(rep, [dirty_sql], RESOURCE) + got = {f[1] for f in rep.findings} + for rule in ("E-DB-TABLE", "E-DB-COL", "E-DB-ID", "E-DB-FK"): + self.assertIn(rule, got, "expected %s to fire" % rule) + + def test_tags_clean_and_dirty(self): + clean_tags = os.path.join(self.tmp, "clean-tags.json") + dirty_tags = os.path.join(self.tmp, "dirty-tags.json") + write_json(clean_tags, TAGS_CLEAN) + write_json(dirty_tags, TAGS_DIRTY) + + rep = el.Report() + el.check_tags(rep, [clean_tags], RESOURCE) + self.assertEqual(rep.findings, [], + "conforming tag export produced findings: %s" % rep.findings) + + rep = el.Report() + el.check_tags(rep, [dirty_tags], RESOURCE) + got = {f[1] for f in rep.findings} + self.assertIn("E-NS-ROOT", got) + self.assertIn("E-TAG-NAME", got) + + # -- discovery -------------------------------------------------------- + + def test_discovers_both_projects_from_a_containing_folder(self): + found = el.discover_projects(self.tmp) + self.assertEqual(sorted(os.path.basename(p) for p in found), + ["SampleResource_v2", "sample-resource"]) + + def test_project_folder_resolves_to_itself(self): + self.assertEqual(el.discover_projects(self.clean), [self.clean]) + + def test_zip_export_is_unpacked(self): + archive = shutil.make_archive( + os.path.join(self.tmp, "export"), "zip", + root_dir=os.path.dirname(self.clean), + base_dir=os.path.basename(self.clean)) + found = el.discover_projects(archive) + self.assertEqual(len(found), 1) + self.assertTrue(os.path.exists(os.path.join(found[0], "project.json"))) + + def test_resource_name_inferred_from_namespace(self): + self.assertEqual(el.infer_resource(self.clean, "sample-resource"), RESOURCE) + + # -- name predicates -------------------------------------------------- + + def test_name_predicates(self): + self.assertTrue(el.is_pascal("AreaCard")) + self.assertFalse(el.is_pascal("areaCard")) + self.assertTrue(el.is_camel("startMs")) + self.assertFalse(el.is_camel("start_ms")) + self.assertTrue(el.is_kebab("bad-actors")) + self.assertFalse(el.is_kebab("BadActors")) + self.assertTrue(el.is_title("Alarm Dashboard")) + self.assertTrue(el.is_title("Table of Contents")) + self.assertFalse(el.is_title("alarm dashboard")) + self.assertTrue(el.is_title_or_pascal("AlarmDashboard")) + + def test_rule_table_ids_are_unique_and_referenced(self): + ids = [r[0] for r in el.RULES] + self.assertEqual(len(ids), len(set(ids)), "duplicate rule id in RULES") + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), + "exchange_lint.py")) as f: + source = f.read() + for rid in ids: + self.assertGreaterEqual(source.count('"%s"' % rid), 2, + "%s is declared but never raised" % rid) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/page-config/config.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/page-config/config.json new file mode 100644 index 0000000..b41ed9d --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/page-config/config.json @@ -0,0 +1,9 @@ +{ + "pages": { + "/": { + "title": "Alarm Analysis", + "viewPath": "AlarmDashboard/Dashboard" + } + }, + "sharedDocks": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/page-config/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/page-config/resource.json new file mode 100644 index 0000000..b219fa3 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/page-config/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "config.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/session-props/props.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/session-props/props.json new file mode 100644 index 0000000..9975dd7 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/session-props/props.json @@ -0,0 +1,95 @@ +{ + "custom": { + "AlarmDashboard": { + "query": { + "areas": [], + "endMs": 0, + "filterDow": -1, + "filterHour": -1, + "priorities": [], + "rangePreset": "8h", + "refreshToken": 0, + "search": "", + "startMs": 0, + "states": [] + }, + "ui": { + "badActorFocus": "", + "selectedTab": 0 + } + } + }, + "propConfig": { + "custom.AlarmDashboard": { + "persistent": true + }, + "props.auth": { + "access": "PRIVATE", + "persistent": false + }, + "props.device.accelerometer": { + "access": "SYSTEM", + "persistent": false + }, + "props.device.identifier": { + "access": "SYSTEM", + "persistent": false + }, + "props.device.timezone": { + "access": "SYSTEM", + "persistent": false + }, + "props.device.type": { + "access": "SYSTEM", + "persistent": false + }, + "props.device.userAgent": { + "access": "SYSTEM", + "persistent": false + }, + "props.gateway": { + "access": "SYSTEM", + "persistent": false + }, + "props.geolocation.data": { + "access": "SYSTEM", + "persistent": false + }, + "props.geolocation.permissionGranted": { + "access": "SYSTEM", + "persistent": false + }, + "props.host": { + "access": "SYSTEM", + "persistent": false + }, + "props.id": { + "access": "SYSTEM", + "persistent": false + }, + "props.lastActivity": { + "access": "SYSTEM", + "persistent": false + }, + "props.offline.capable": { + "access": "SYSTEM", + "persistent": false + }, + "props.offline.enabled": { + "access": "SYSTEM", + "persistent": false + }, + "props.offline.lastSynced": { + "access": "SYSTEM", + "persistent": false + } + }, + "props": { + "device": {}, + "geolocation": {}, + "locale": "en-US", + "offline": {}, + "securityLevels": [], + "theme": "light-cool" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/session-props/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/session-props/resource.json new file mode 100644 index 0000000..241c5f9 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/session-props/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "props.json" + ], + "attributes": { + "lastModificationSignature": "3859d47a0f5515c27fb6706190bb2c747a6e6a071f652dcda6a017fcc9a97c88", + "lastModification": { + "actor": "admin", + "timestamp": "2026-08-11T20:12:19Z" + } + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Card/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Card/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Card/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Card/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Card/style.json new file mode 100644 index 0000000..a854646 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Card/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "var(--neutral-10)", + "borderRadius": "8px", + "boxShadow": "0 1px 3px rgba(0,0,0,0.12)", + "padding": "12px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/CardTitle/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/CardTitle/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/CardTitle/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/CardTitle/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/CardTitle/style.json new file mode 100644 index 0000000..3692810 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/CardTitle/style.json @@ -0,0 +1,12 @@ +{ + "base": { + "style": { + "color": "var(--neutral-70)", + "fontSize": "12px", + "fontWeight": "bold", + "letterSpacing": "0.08em", + "marginBottom": "8px", + "textTransform": "uppercase" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Bad/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Bad/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Bad/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Bad/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Bad/style.json new file mode 100644 index 0000000..e0f5205 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Bad/style.json @@ -0,0 +1,13 @@ +{ + "base": { + "style": { + "backgroundColor": "#D6455026", + "borderRadius": "10px", + "color": "#D64550", + "fontSize": "11px", + "fontWeight": "bold", + "paddingLeft": "6px", + "paddingRight": "6px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Flat/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Flat/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Flat/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Flat/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Flat/style.json new file mode 100644 index 0000000..502ed05 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Flat/style.json @@ -0,0 +1,13 @@ +{ + "base": { + "style": { + "backgroundColor": "var(--neutral-30)", + "borderRadius": "10px", + "color": "var(--neutral-70)", + "fontSize": "11px", + "fontWeight": "bold", + "paddingLeft": "6px", + "paddingRight": "6px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Good/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Good/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Good/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Good/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Good/style.json new file mode 100644 index 0000000..43d83d2 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Chip/Good/style.json @@ -0,0 +1,13 @@ +{ + "base": { + "style": { + "backgroundColor": "#2E9E5B26", + "borderRadius": "10px", + "color": "#2E9E5B", + "fontSize": "11px", + "fontWeight": "bold", + "paddingLeft": "6px", + "paddingRight": "6px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Empty/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Empty/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Empty/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Empty/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Empty/style.json new file mode 100644 index 0000000..546e460 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Empty/style.json @@ -0,0 +1,9 @@ +{ + "base": { + "style": { + "color": "var(--neutral-50)", + "fontStyle": "italic", + "textAlign": "center" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/A/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/A/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/A/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/A/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/A/style.json new file mode 100644 index 0000000..8687a3a --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/A/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#2E9E5B", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/B/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/B/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/B/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/B/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/B/style.json new file mode 100644 index 0000000..00fc826 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/B/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#7CB342", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/C/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/C/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/C/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/C/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/C/style.json new file mode 100644 index 0000000..90da82f --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/C/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#E5C453", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/D/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/D/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/D/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/D/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/D/style.json new file mode 100644 index 0000000..2e5d41a --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/D/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#E8883A", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/F/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/F/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/F/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/F/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/F/style.json new file mode 100644 index 0000000..83a3fd1 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/F/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#D64550", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/NA/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/NA/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/NA/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/NA/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/NA/style.json new file mode 100644 index 0000000..7407c82 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Grade/NA/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#8A94A6", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PageBg/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PageBg/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PageBg/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PageBg/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PageBg/style.json new file mode 100644 index 0000000..5b96b14 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PageBg/style.json @@ -0,0 +1,8 @@ +{ + "base": { + "style": { + "backgroundColor": "var(--neutral-20)", + "padding": "12px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Critical/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Critical/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Critical/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Critical/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Critical/style.json new file mode 100644 index 0000000..c2b98f9 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Critical/style.json @@ -0,0 +1,15 @@ +{ + "base": { + "style": { + "backgroundColor": "#D64550", + "borderRadius": "4px", + "color": "#FFFFFF", + "fontSize": "12px", + "fontWeight": "bold", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Diagnostic/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Diagnostic/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Diagnostic/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Diagnostic/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Diagnostic/style.json new file mode 100644 index 0000000..0cc5b4b --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Diagnostic/style.json @@ -0,0 +1,15 @@ +{ + "base": { + "style": { + "backgroundColor": "#8A94A6", + "borderRadius": "4px", + "color": "#FFFFFF", + "fontSize": "12px", + "fontWeight": "bold", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/High/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/High/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/High/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/High/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/High/style.json new file mode 100644 index 0000000..0be2860 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/High/style.json @@ -0,0 +1,15 @@ +{ + "base": { + "style": { + "backgroundColor": "#E8883A", + "borderRadius": "4px", + "color": "#FFFFFF", + "fontSize": "12px", + "fontWeight": "bold", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Low/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Low/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Low/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Low/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Low/style.json new file mode 100644 index 0000000..b1bd3c2 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Low/style.json @@ -0,0 +1,15 @@ +{ + "base": { + "style": { + "backgroundColor": "#5B9BD5", + "borderRadius": "4px", + "color": "#FFFFFF", + "fontSize": "12px", + "fontWeight": "bold", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Medium/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Medium/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Medium/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Medium/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Medium/style.json new file mode 100644 index 0000000..e4881aa --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Priority/Medium/style.json @@ -0,0 +1,15 @@ +{ + "base": { + "style": { + "backgroundColor": "#E5C453", + "borderRadius": "4px", + "color": "#FFFFFF", + "fontSize": "12px", + "fontWeight": "bold", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Critical/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Critical/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Critical/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Critical/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Critical/style.json new file mode 100644 index 0000000..7fdf1d9 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Critical/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#D6455026", + "borderRadius": "6px", + "color": "#D64550", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Diagnostic/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Diagnostic/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Diagnostic/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Diagnostic/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Diagnostic/style.json new file mode 100644 index 0000000..3d2bc2a --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Diagnostic/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#8A94A626", + "borderRadius": "6px", + "color": "#8A94A6", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/High/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/High/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/High/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/High/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/High/style.json new file mode 100644 index 0000000..a4b447e --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/High/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#E8883A26", + "borderRadius": "6px", + "color": "#E8883A", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Low/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Low/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Low/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Low/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Low/style.json new file mode 100644 index 0000000..bd7ae88 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Low/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#5B9BD526", + "borderRadius": "6px", + "color": "#5B9BD5", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Medium/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Medium/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Medium/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Medium/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Medium/style.json new file mode 100644 index 0000000..32eb40d --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/PrioritySoft/Medium/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#E5C45326", + "borderRadius": "6px", + "color": "#E5C453", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Acked/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Acked/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Acked/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Acked/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Acked/style.json new file mode 100644 index 0000000..a353a46 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Acked/style.json @@ -0,0 +1,8 @@ +{ + "base": { + "style": { + "backgroundColor": "#3B7DD818", + "color": "#3B7DD8" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Active/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Active/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Active/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Active/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Active/style.json new file mode 100644 index 0000000..6945a81 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Active/style.json @@ -0,0 +1,9 @@ +{ + "base": { + "style": { + "backgroundColor": "#D6455018", + "color": "#D64550", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Cleared/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Cleared/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Cleared/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Cleared/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Cleared/style.json new file mode 100644 index 0000000..e3001f8 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/State/Cleared/style.json @@ -0,0 +1,8 @@ +{ + "base": { + "style": { + "backgroundColor": "#2E9E5B18", + "color": "#2E9E5B" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Active/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Active/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Active/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Active/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Active/style.json new file mode 100644 index 0000000..161235e --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Active/style.json @@ -0,0 +1,16 @@ +{ + "base": { + "style": { + "borderBottomColor": "#3B7DD8", + "borderBottomStyle": "solid", + "borderBottomWidth": "3px", + "color": "var(--neutral-90)", + "cursor": "pointer", + "fontWeight": "bold", + "paddingBottom": "6px", + "paddingLeft": "16px", + "paddingRight": "16px", + "paddingTop": "8px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Inactive/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Inactive/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Inactive/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Inactive/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Inactive/style.json new file mode 100644 index 0000000..d5c7c93 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Tab/Inactive/style.json @@ -0,0 +1,23 @@ +{ + "base": { + "style": { + "borderBottomColor": "rgba(0,0,0,0)", + "borderBottomStyle": "solid", + "borderBottomWidth": "3px", + "color": "var(--neutral-60)", + "cursor": "pointer", + "paddingBottom": "6px", + "paddingLeft": "16px", + "paddingRight": "16px", + "paddingTop": "8px" + } + }, + "variants": [ + { + "pseudo": "hover", + "style": { + "color": "var(--neutral-90)" + } + } + ] +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Big/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Big/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Big/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Big/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Big/style.json new file mode 100644 index 0000000..f5e71ef --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Big/style.json @@ -0,0 +1,9 @@ +{ + "base": { + "style": { + "color": "var(--neutral-90)", + "fontSize": "28px", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Kpi/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Kpi/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Kpi/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Kpi/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Kpi/style.json new file mode 100644 index 0000000..57c2b23 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Kpi/style.json @@ -0,0 +1,9 @@ +{ + "base": { + "style": { + "color": "var(--neutral-90)", + "fontSize": "22px", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Muted/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Muted/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Muted/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Muted/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Muted/style.json new file mode 100644 index 0000000..ddf5eab --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Text/Muted/style.json @@ -0,0 +1,8 @@ +{ + "base": { + "style": { + "color": "var(--neutral-60)", + "fontSize": "12px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Toolbar/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Toolbar/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Toolbar/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Toolbar/style.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Toolbar/style.json new file mode 100644 index 0000000..125b252 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/style-classes/AlarmDashboard/Toolbar/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "var(--neutral-10)", + "borderRadius": "8px", + "boxShadow": "0 1px 3px rgba(0,0,0,0.12)", + "padding": "8px" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/AreaCard/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/AreaCard/resource.json new file mode 100644 index 0000000..7e344b1 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/AreaCard/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "a606602b42f664df14e62d1509a4368ffc33d03863d17bc20a0bc6ed5124b07b", + "lastModification": { + "actor": "admin", + "timestamp": "2026-08-11T20:06:43Z" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/AreaCard/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/AreaCard/view.json new file mode 100644 index 0000000..c9d335f --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/AreaCard/view.json @@ -0,0 +1,163 @@ +{ + "custom": {}, + "params": {}, + "propConfig": { + "params.area": { + "paramDirection": "input", + "persistent": false + }, + "params.top": { + "paramDirection": "input", + "persistent": false + }, + "params.total": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 240, + "width": 340 + } + }, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblArea" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.area" + }, + "transforms": [ + { + "code": "\treturn value or \u0027\u0027", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "15px", + "fontWeight": "bold", + "overflow": "hidden", + "textOverflow": "ellipsis", + "whiteSpace": "nowrap" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "chipTotal" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.total" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn \u0027%s activations\u0027 % AlarmDashboard.fmt.num(int(value or 0))\n\texcept:\n\t\treturn \u0027\u0027", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Chip/Flat", + "padding": "2px 8px" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "headerRow" + }, + "position": { + "basis": "26px", + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "repTop" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.top" + }, + "transforms": [ + { + "code": "\tout \u003d []\n\tfor r in (value or []):\n\t\ttry:\n\t\t\tout.append({\u0027rank\u0027: int(r[\u0027rank\u0027]), \u0027source\u0027: r[\u0027source\u0027], \u0027label\u0027: r[\u0027label\u0027], \u0027count\u0027: int(r[\u0027count\u0027]), \u0027pct\u0027: float(r[\u0027pct\u0027])})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "elementPosition": { + "basis": "34px", + "grow": 0, + "shrink": 0 + }, + "path": "AlarmDashboard/Components/SourceRow", + "style": { + "gap": "4px", + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "8px", + "overflow": "hidden" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/EmptyState/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/EmptyState/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/EmptyState/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/EmptyState/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/EmptyState/view.json new file mode 100644 index 0000000..fbddae0 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/EmptyState/view.json @@ -0,0 +1,93 @@ +{ + "custom": {}, + "params": { + "icon": null, + "message": null + }, + "propConfig": { + "params.icon": { + "paramDirection": "input", + "persistent": false + }, + "params.message": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "icon" + }, + "position": { + "basis": "48px", + "shrink": 0 + }, + "propConfig": { + "props.path": { + "binding": { + "config": { + "path": "view.params.icon" + }, + "transforms": [ + { + "code": "\treturn 'material/' + (value or 'inbox')", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "color": "var(--neutral-50)", + "path": "material/inbox", + "style": { + "fontSize": "40px" + } + }, + "type": "ia.display.icon" + }, + { + "meta": { + "name": "msg" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.message" + }, + "transforms": [ + { + "code": "\treturn value or 'No alarm events in the selected range'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Empty" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "alignItems": "center", + "justifyContent": "center" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HealthGauge/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HealthGauge/resource.json new file mode 100644 index 0000000..1807fb7 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HealthGauge/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "44b87b06b842940d391b9bafd34557de0ac8a5005f6ee91844ac9dd2f18c0f04", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:14:21Z" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HealthGauge/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HealthGauge/view.json new file mode 100644 index 0000000..e14ca4c --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HealthGauge/view.json @@ -0,0 +1,134 @@ +{ + "custom": {}, + "params": {}, + "propConfig": { + "params.health": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "grade" + }, + "propConfig": { + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tg \u003d value[\u0027grade\u0027]\n\texcept:\n\t\tg \u003d None\n\treturn \u0027AlarmDashboard/Grade/\u0027 + (g if g in [\u0027A\u0027, \u0027B\u0027, \u0027C\u0027, \u0027D\u0027, \u0027F\u0027] else \u0027NA\u0027)", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value[\u0027grade\u0027] or \u0027N/A\u0027\n\texcept:\n\t\treturn \u0027N/A\u0027", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "30px", + "paddingBottom": "2px", + "paddingLeft": "14px", + "paddingRight": "14px", + "paddingTop": "2px", + "textAlign": "center" + } + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "meta": { + "name": "title" + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold" + }, + "text": "Alarm Health" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "score" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ts \u003d value[\u0027score\u0027]\n\t\treturn \u0027Score %d - click for detail\u0027 % round(s) if s is not None else \u0027no data in range\u0027\n\texcept:\n\t\treturn \u0027no data in range\u0027", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "scoreCol" + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + } + ], + "events": { + "dom": { + "onClick": { + "config": { + "script": "\tsystem.perspective.openPopup(\u0027AD_HealthScore\u0027, \u0027AlarmDashboard/Popups/HealthScore\u0027, params \u003d {\u0027health\u0027: self.view.params.health}, showCloseIcon \u003d True, draggable \u003d True)" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "root" + }, + "props": { + "style": { + "alignItems": "center", + "cursor": "pointer", + "gap": "10px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HeatmapCell/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HeatmapCell/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HeatmapCell/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HeatmapCell/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HeatmapCell/view.json new file mode 100644 index 0000000..f9e99e2 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/HeatmapCell/view.json @@ -0,0 +1,129 @@ +{ + "custom": {}, + "params": { + "count": null, + "max": null, + "dow": null, + "hour": null + }, + "propConfig": { + "params.count": { + "paramDirection": "input", + "persistent": false + }, + "params.max": { + "paramDirection": "input", + "persistent": false + }, + "params.dow": { + "paramDirection": "input", + "persistent": false + }, + "params.hour": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "cell" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.style.backgroundColor": { + "binding": { + "config": { + "path": "view.params.count" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tm = self.view.params.max\n\t\tc = value or 0\n\t\tif not m or m <= 0 or c <= 0:\n\t\t\treturn 'rgba(59,125,216,0.06)'\n\t\ta = 0.15 + 0.85 * (float(c) / m)\n\t\treturn 'rgba(59,125,216,%.2f)' % min(a, 1.0)\n\texcept:\n\t\treturn 'rgba(59,125,216,0.06)'", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.color": { + "binding": { + "config": { + "path": "view.params.count" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tm = self.view.params.max\n\t\tif m and value and float(value) / m > 0.55:\n\t\t\treturn '#FFFFFF'\n\texcept:\n\t\tpass\n\treturn 'var(--neutral-90)'", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.count" + }, + "transforms": [ + { + "code": "\treturn '' if not value else value", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.cursor": { + "binding": { + "config": { + "path": "view.params.dow" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn 'pointer' if value is not None else 'default'\n\texcept:\n\t\treturn 'default'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "borderRadius": "2px", + "fontSize": "10px", + "marginBottom": "1px", + "marginRight": "1px", + "textAlign": "center" + } + }, + "type": "ia.display.label", + "events": { + "dom": { + "onClick": { + "config": { + "script": "\ttry:\n\t\td = self.view.params.dow\n\t\th = self.view.params.hour\n\t\tif d is None or h is None:\n\t\t\treturn\n\t\tq = self.session.custom.AlarmDashboard.query\n\t\tq.filterDow = int(d)\n\t\tq.filterHour = int(h)\n\t\tself.session.custom.AlarmDashboard.ui.selectedTab = 3\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + } + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "row", + "style": { + "alignItems": "stretch" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/InsightRow/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/InsightRow/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/InsightRow/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/InsightRow/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/InsightRow/view.json new file mode 100644 index 0000000..6072c17 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/InsightRow/view.json @@ -0,0 +1,121 @@ +{ + "custom": {}, + "params": { + "icon": null, + "severity": null, + "tab": null, + "text": null + }, + "propConfig": { + "params.icon": { + "paramDirection": "input", + "persistent": false + }, + "params.severity": { + "paramDirection": "input", + "persistent": false + }, + "params.tab": { + "paramDirection": "input", + "persistent": false + }, + "params.text": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "icon" + }, + "position": { + "basis": "24px", + "shrink": 0 + }, + "propConfig": { + "props.color": { + "binding": { + "config": { + "path": "view.params.severity" + }, + "transforms": [ + { + "code": "\tcolors = {0: '#D64550', 1: '#E8883A', 2: '#3B7DD8', 3: 'var(--neutral-60)'}\n\treturn colors.get(value, 'var(--neutral-60)')", + "type": "script" + } + ], + "type": "property" + } + }, + "props.path": { + "binding": { + "config": { + "path": "view.params.icon" + }, + "transforms": [ + { + "code": "\treturn 'material/' + (value or 'insights')", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "path": "material/insights", + "style": { + "fontSize": "18px" + } + }, + "type": "ia.display.icon" + }, + { + "meta": { + "name": "text" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.text" + }, + "transforms": [ + { + "code": "\treturn value or ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "row", + "style": { + "alignItems": "center", + "gap": "8px", + "paddingBottom": "4px", + "paddingTop": "4px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/KpiCard/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/KpiCard/resource.json new file mode 100644 index 0000000..f597cc7 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/KpiCard/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "75b1aa709e8638ac045f15b7a6d3543d023b8f3e886e2f9f1c263c0390c4fe9d", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:45:13Z" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/KpiCard/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/KpiCard/view.json new file mode 100644 index 0000000..24f3e82 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/KpiCard/view.json @@ -0,0 +1,199 @@ +{ + "custom": {}, + "params": {}, + "propConfig": { + "params.delta": { + "paramDirection": "input", + "persistent": false + }, + "params.label": { + "paramDirection": "input", + "persistent": false + }, + "params.unit": { + "paramDirection": "input", + "persistent": false + }, + "params.value": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "lbl" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.label" + }, + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "lineHeight": "1.1" + } + }, + "type": "ia.display.label", + "position": { + "shrink": 0 + } + }, + { + "children": [ + { + "meta": { + "name": "value" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.value" + }, + "transforms": [ + { + "code": "\treturn value if value is not None else '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Kpi", + "lineHeight": "1.1" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "unit" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.unit" + }, + "transforms": [ + { + "code": "\treturn value or ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "lineHeight": "1.1" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "valueRow" + }, + "position": { + "grow": 1, + "shrink": 1, + "basis": "auto" + }, + "props": { + "style": { + "alignItems": "baseline", + "gap": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "chip" + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.delta" + }, + "transforms": [ + { + "code": "\treturn value is not None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.delta" + }, + "transforms": [ + { + "code": "\tbase = 'AlarmDashboard/Chip/Flat'\n\ttry:\n\t\tif value and value['good'] == True:\n\t\t\tbase = 'AlarmDashboard/Chip/Good'\n\t\telif value and value['good'] == False:\n\t\t\tbase = 'AlarmDashboard/Chip/Bad'\n\texcept:\n\t\tpass\n\treturn base", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.delta" + }, + "transforms": [ + { + "code": "\tif not value:\n\t\treturn ''\n\ttry:\n\t\tchip = AlarmDashboard.fmt.delta_chip(value)\n\t\treturn '%s %s' % (chip['arrow'], chip['text'])\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "alignSelf": "flex-start", + "padding": "0 6px" + } + }, + "type": "ia.display.label", + "position": { + "shrink": 0 + } + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "justifyContent": "space-between", + "overflow": "hidden", + "padding": "6px 12px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/PriorityCard/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/PriorityCard/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/PriorityCard/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/PriorityCard/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/PriorityCard/view.json new file mode 100644 index 0000000..2267054 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/PriorityCard/view.json @@ -0,0 +1,137 @@ +{ + "custom": {}, + "params": { + "count": null, + "pct": null, + "priority_name": null + }, + "propConfig": { + "params.count": { + "paramDirection": "input", + "persistent": false + }, + "params.pct": { + "paramDirection": "input", + "persistent": false + }, + "params.priority_name": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "name" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.priority_name" + }, + "transforms": [ + { + "code": "\treturn value or 'Unknown'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "count" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.count" + }, + "transforms": [ + { + "code": "\treturn AlarmDashboard.fmt.num(value)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Big" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "pct" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.pct" + }, + "transforms": [ + { + "code": "\treturn AlarmDashboard.fmt.pct(value) + ' of events' if value is not None else ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "propConfig": { + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.priority_name" + }, + "transforms": [ + { + "code": "\tnames = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tn = value if value in names else 'Diagnostic'\n\treturn 'AlarmDashboard/Card AlarmDashboard/PrioritySoft/' + n", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "alignItems": "center", + "classes": "AlarmDashboard/Card", + "gap": "2px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/ScoreBar/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/ScoreBar/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/ScoreBar/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/ScoreBar/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/ScoreBar/view.json new file mode 100644 index 0000000..5c72dbb --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/ScoreBar/view.json @@ -0,0 +1,207 @@ +{ + "custom": {}, + "params": { + "detail": null, + "key": null, + "label": null, + "score": null, + "weight": null + }, + "propConfig": { + "params.detail": { + "paramDirection": "input", + "persistent": false + }, + "params.key": { + "paramDirection": "input", + "persistent": false + }, + "params.label": { + "paramDirection": "input", + "persistent": false + }, + "params.score": { + "paramDirection": "input", + "persistent": false + }, + "params.weight": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "lbl" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.label" + }, + "transforms": [ + { + "code": "\treturn value or ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "score" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.score" + }, + "transforms": [ + { + "code": "\treturn '%d' % round(value) if value is not None else 'N/A'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "topRow" + }, + "props": { + "direction": "row", + "style": { + "justifyContent": "space-between" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [], + "meta": { + "name": "fill" + }, + "propConfig": { + "props.style.backgroundColor": { + "binding": { + "config": { + "path": "view.params.score" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ts = float(value)\n\texcept:\n\t\treturn 'var(--neutral-40)'\n\tif s >= 80:\n\t\treturn '#2E9E5B'\n\tif s >= 60:\n\t\treturn '#E5C453'\n\treturn '#D64550'", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.width": { + "binding": { + "config": { + "path": "view.params.score" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn '%d%%' % max(0, min(100, round(value)))\n\texcept:\n\t\treturn '0%'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "row", + "style": { + "borderRadius": "4px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "track" + }, + "position": { + "basis": "8px", + "shrink": 0 + }, + "props": { + "direction": "row", + "style": { + "backgroundColor": "var(--neutral-30)", + "borderRadius": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "detail" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.detail" + }, + "transforms": [ + { + "code": "\treturn value or ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "gap": "2px", + "paddingBottom": "8px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/SourceRow/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/SourceRow/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/SourceRow/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/SourceRow/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/SourceRow/view.json new file mode 100644 index 0000000..472c3d4 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/SourceRow/view.json @@ -0,0 +1,192 @@ +{ + "custom": {}, + "params": { + "count": null, + "label": null, + "pct": null, + "rank": null, + "source": null + }, + "propConfig": { + "params.count": { + "paramDirection": "input", + "persistent": false + }, + "params.label": { + "paramDirection": "input", + "persistent": false + }, + "params.pct": { + "paramDirection": "input", + "persistent": false + }, + "params.rank": { + "paramDirection": "input", + "persistent": false + }, + "params.source": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "rank" + }, + "position": { + "basis": "24px", + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.rank" + }, + "transforms": [ + { + "code": "\treturn '#%s' % value if value is not None else ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "lbl" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.label" + }, + "transforms": [ + { + "code": "\treturn value or ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "count" + }, + "position": { + "basis": "56px", + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.count" + }, + "transforms": [ + { + "code": "\treturn AlarmDashboard.fmt.num(value)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold", + "textAlign": "right" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "pct" + }, + "position": { + "basis": "48px", + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.pct" + }, + "transforms": [ + { + "code": "\treturn AlarmDashboard.fmt.pct(value) if value is not None else ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "textAlign": "right" + } + }, + "type": "ia.display.label" + } + ], + "events": { + "dom": { + "onClick": { + "config": { + "script": "\tself.session.custom.AlarmDashboard.ui.badActorFocus = self.view.params.source\n\tself.session.custom.AlarmDashboard.ui.selectedTab = 2" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "root" + }, + "props": { + "direction": "row", + "style": { + "alignItems": "center", + "borderBottomColor": "var(--neutral-30)", + "borderBottomStyle": "solid", + "borderBottomWidth": "1px", + "cursor": "pointer", + "gap": "8px", + "paddingBottom": "4px", + "paddingTop": "4px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/TabButton/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/TabButton/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/TabButton/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/TabButton/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/TabButton/view.json new file mode 100644 index 0000000..e72c4e9 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Components/TabButton/view.json @@ -0,0 +1,69 @@ +{ + "custom": {}, + "params": { + "index": null, + "title": null + }, + "propConfig": { + "params.index": { + "paramDirection": "input", + "persistent": false + }, + "params.title": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "events": { + "dom": { + "onClick": { + "config": { + "script": "\tself.session.custom.AlarmDashboard.ui.selectedTab = self.view.params.index" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btn" + }, + "propConfig": { + "props.style.classes": { + "binding": { + "config": { + "expression": "if({view.params.index} = {session.custom.AlarmDashboard.ui.selectedTab}, 'AlarmDashboard/Tab/Active', 'AlarmDashboard/Tab/Inactive')" + }, + "type": "expr" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.title" + }, + "type": "property" + } + } + }, + "props": {}, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "row", + "style": { + "alignItems": "center", + "justifyContent": "center" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Dashboard/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Dashboard/resource.json new file mode 100644 index 0000000..d0fbe8c --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Dashboard/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "04bdfb9c4eecc4f8cf35a85994e6790ef8d62a1d1aa00553e4dc4193e171a110", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:31:35Z" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Dashboard/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Dashboard/view.json new file mode 100644 index 0000000..9c1975f --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Dashboard/view.json @@ -0,0 +1,377 @@ +{ + "custom": {}, + "events": { + "system": { + "onStartup": { + "config": { + "script": "\tq = self.session.custom.AlarmDashboard.query\n\tspans = {'4h': 4, '8h': 8, '24h': 24, '7d': 168}\n\tpreset = q.rangePreset\n\tnow = system.date.toMillis(system.date.now())\n\tif preset in spans:\n\t\t# relative presets re-anchor to now on every session start\n\t\tq.endMs = now\n\t\tq.startMs = now - spans[preset] * 3600 * 1000\n\telif not q.startMs or q.startMs == 0:\n\t\tq.endMs = now\n\t\tq.startMs = now - 8 * 3600 * 1000\n\t\tq.rangePreset = '8h'" + }, + "scope": "G", + "type": "script" + } + } + }, + "params": {}, + "propConfig": { + "custom.bundle": { + "binding": { + "config": { + "expression": "{session.custom.AlarmDashboard.query.refreshToken} + '|' + {session.custom.AlarmDashboard.query.startMs} + '|' + {session.custom.AlarmDashboard.query.endMs}" + }, + "transforms": [ + { + "code": "\tq = self.session.custom.AlarmDashboard.query\n\tif not q.startMs or not q.endMs:\n\t\treturn None\n\treturn AlarmDashboard.alarms.getDashboardBundle(q.startMs, q.endMs, {'priorities': list(q.priorities), 'areas': list(q.areas), 'states': list(q.states), 'search': q.search})", + "type": "script" + } + ], + "type": "expr" + } + } + }, + "props": { + "defaultSize": { + "height": 900, + "width": 1600 + } + }, + "root": { + "children": [ + { + "meta": { + "name": "header" + }, + "position": { + "basis": "150px", + "shrink": 0 + }, + "propConfig": { + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + } + }, + "props": { + "path": "AlarmDashboard/Shell/Header" + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "meta": { + "name": "tabBtn0" + }, + "position": { + "basis": "140px", + "shrink": 0 + }, + "props": { + "params": { + "index": 0, + "title": "Overview" + }, + "path": "AlarmDashboard/Components/TabButton" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "tabBtn1" + }, + "position": { + "basis": "140px", + "shrink": 0 + }, + "props": { + "params": { + "index": 1, + "title": "Analysis" + }, + "path": "AlarmDashboard/Components/TabButton" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "tabBtn2" + }, + "position": { + "basis": "140px", + "shrink": 0 + }, + "props": { + "params": { + "index": 2, + "title": "Bad Actors" + }, + "path": "AlarmDashboard/Components/TabButton" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "tabBtn3" + }, + "position": { + "basis": "140px", + "shrink": 0 + }, + "props": { + "params": { + "index": 3, + "title": "Journal" + }, + "path": "AlarmDashboard/Components/TabButton" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "tabBtn4" + }, + "position": { + "basis": "140px", + "shrink": 0 + }, + "props": { + "params": { + "index": 4, + "title": "Areas" + }, + "path": "AlarmDashboard/Components/TabButton" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "tabBar" + }, + "position": { + "basis": "42px", + "shrink": 0 + }, + "props": { + "style": { + "borderBottomColor": "var(--neutral-40)", + "borderBottomStyle": "solid", + "borderBottomWidth": "1px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "filterBar" + }, + "position": { + "basis": "auto", + "shrink": 0 + }, + "propConfig": { + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + }, + "position.display": { + "binding": { + "config": { + "expression": "{session.custom.AlarmDashboard.ui.selectedTab} != 0" + }, + "type": "expr" + } + } + }, + "props": { + "path": "AlarmDashboard/Shell/FilterBar", + "style": { + "minHeight": "56px" + } + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "meta": { + "name": "tabOverview" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{session.custom.AlarmDashboard.ui.selectedTab} = 0" + }, + "type": "expr" + } + }, + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + } + }, + "props": { + "path": "AlarmDashboard/Tabs/Overview" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "tabAnalysis" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{session.custom.AlarmDashboard.ui.selectedTab} = 1" + }, + "type": "expr" + } + }, + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + } + }, + "props": { + "path": "AlarmDashboard/Tabs/Analysis" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "tabBadActors" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{session.custom.AlarmDashboard.ui.selectedTab} = 2" + }, + "type": "expr" + } + }, + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + } + }, + "props": { + "path": "AlarmDashboard/Tabs/BadActors" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "tabJournal" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{session.custom.AlarmDashboard.ui.selectedTab} = 3" + }, + "type": "expr" + } + }, + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + } + }, + "props": { + "path": "AlarmDashboard/Tabs/Journal" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "tabAreas" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{session.custom.AlarmDashboard.ui.selectedTab} = 4" + }, + "type": "expr" + } + }, + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + } + }, + "props": { + "path": "AlarmDashboard/Tabs/Areas" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "tabContent" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/PageBg", + "gap": "10px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/AlarmDetail/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/AlarmDetail/resource.json new file mode 100644 index 0000000..fc7d184 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/AlarmDetail/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "1d6feda3a22e57c52a032bea5ed828b0b7836e249e46a68faa58af39207b9119", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:41:00Z" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/AlarmDetail/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/AlarmDetail/view.json new file mode 100644 index 0000000..fd89214 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/AlarmDetail/view.json @@ -0,0 +1,811 @@ +{ + "custom": {}, + "params": {}, + "propConfig": { + "custom.detail": { + "binding": { + "config": { + "expression": "{view.params.source} + '|' + {session.custom.AlarmDashboard.query.startMs} + '|' + {session.custom.AlarmDashboard.query.endMs}" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tsrc = self.view.params.source\n\texcept:\n\t\tsrc = None\n\tif not src:\n\t\treturn None\n\ttry:\n\t\tq = self.session.custom.AlarmDashboard.query\n\t\treturn AlarmDashboard.alarms.getSourceDetail(src, q.startMs, q.endMs)\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "expr" + } + }, + "params.label": { + "paramDirection": "input", + "persistent": false + }, + "params.source": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 660, + "width": 720 + } + }, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblTitle" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.label" + }, + "transforms": [ + { + "code": "\tif value:\n\t\treturn value\n\ttry:\n\t\treturn self.view.params.source or 'Alarm Detail'\n\texcept:\n\t\treturn 'Alarm Detail'", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.fontSize": { + "binding": { + "config": { + "path": "this.props.text" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tn = len(value or '')\n\texcept:\n\t\tn = 0\n\tif n <= 34:\n\t\treturn '28px'\n\tsize = int(28.0 * 34 / n)\n\tif size < 12:\n\t\tsize = 12\n\treturn '%dpx' % size", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Big", + "minWidth": "0px", + "whiteSpace": "nowrap" + } + }, + "type": "ia.display.label", + "position": { + "basis": "auto", + "grow": 1, + "shrink": 1 + } + }, + { + "meta": { + "name": "lblArea" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['area'] or ''\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "headerCol" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "kpiCount" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.value": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.num(value['stats']['count'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Activations" + }, + "path": "AlarmDashboard/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiTta" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.value": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.dur(value['stats']['avg_tta_ms'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Avg Time to Ack" + }, + "path": "AlarmDashboard/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiActive" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.value": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.dur(value['stats']['avg_active_ms'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Avg Active Time" + }, + "path": "AlarmDashboard/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiFleeting" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.value": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.num(value['stats']['fleeting_count'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Fleeting" + }, + "path": "AlarmDashboard/Components/KpiCard" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "statsRow" + }, + "position": { + "basis": "88px", + "shrink": 0 + }, + "props": { + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblDailyTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Daily activity" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repDaily" + }, + "position": { + "basis": "72px", + "shrink": 0 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tdaily = list(value['daily'] or [])\n\texcept:\n\t\tdaily = []\n\tcounts = []\n\tfor d in daily:\n\t\ttry:\n\t\t\tcounts.append(int(d['count']))\n\t\texcept:\n\t\t\tcounts.append(0)\n\tif not counts:\n\t\treturn []\n\tm = max(counts)\n\treturn [{'count': c, 'max': m} for c in counts]", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "8px" + }, + "path": "AlarmDashboard/Components/HeatmapCell", + "style": { + "gap": "2px" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + }, + { + "meta": { + "name": "lblDailyRange" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tdaily = list(value['daily'] or [])\n\texcept:\n\t\tdaily = []\n\tif not daily:\n\t\treturn 'No activity in range'\n\ttry:\n\t\treturn 'Activations per day, %s - %s' % (AlarmDashboard.fmt.day_clock(daily[0]['t0']), AlarmDashboard.fmt.day_clock(daily[-1]['t0']))\n\texcept:\n\t\treturn 'Activations per day'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "cardDaily" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblAckTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Top ack users" + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "meta": { + "name": "ack0" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['stats']['top_ack_users']) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tu = value['stats']['top_ack_users'][0]\n\t\treturn '%s (%d)' % (u['user'], u['count'])\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Chip/Flat", + "paddingBottom": "2px", + "paddingTop": "2px" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ack1" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['stats']['top_ack_users']) > 1\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tu = value['stats']['top_ack_users'][1]\n\t\treturn '%s (%d)' % (u['user'], u['count'])\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Chip/Flat", + "paddingBottom": "2px", + "paddingTop": "2px" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ack2" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['stats']['top_ack_users']) > 2\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tu = value['stats']['top_ack_users'][2]\n\t\treturn '%s (%d)' % (u['user'], u['count'])\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Chip/Flat", + "paddingBottom": "2px", + "paddingTop": "2px" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "lblNoAcks" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['stats']['top_ack_users']) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "No acknowledgements in range" + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "ackRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "6px" + }, + "wrap": "wrap" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "cardAcks" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblEvTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Recent events" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "tblEvents" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.data": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\tout = []\n\ttry:\n\t\trows = list(value['events'] or [])\n\texcept:\n\t\trows = []\n\tfor r in rows[:25]:\n\t\ttry:\n\t\t\tout.append({'time_label': r['time_label'], 'state_label': r['state_label'], 'ack_user': r['ack_user'] or ''})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "time_label", + "header": { + "title": "Time" + }, + "render": "string", + "sortable": false, + "strictWidth": true, + "width": 160 + }, + { + "field": "state_label", + "header": { + "title": "State" + }, + "render": "string", + "sortable": false, + "strictWidth": true, + "width": 110 + }, + { + "field": "ack_user", + "header": { + "title": "Ack by" + }, + "render": "string", + "sortable": false + } + ], + "emptyMessage": { + "noData": { + "text": "No events in range" + } + }, + "pager": { + "bottom": false + } + }, + "type": "ia.display.table" + } + ], + "meta": { + "name": "cardEvents" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "bodyCol" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\tif value is None:\n\t\treturn False\n\ttry:\n\t\treturn not value['error']\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "emptyWrap" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\tif value is None:\n\t\treturn True\n\ttry:\n\t\treturn bool(value['error'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "notifications_off", + "message": "No journal data for this alarm in the selected range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.closePopup('AD_AlarmDetail')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btnClose" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "text": "Close" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "footerRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "flex-end" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/PageBg", + "gap": "10px", + "padding": "16px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/EventDetail/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/EventDetail/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/EventDetail/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/EventDetail/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/EventDetail/view.json new file mode 100644 index 0000000..aa0b355 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/EventDetail/view.json @@ -0,0 +1,683 @@ +{ + "custom": { + "props": [] + }, + "params": { + "event": null + }, + "propConfig": { + "custom.props": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\tev = value\n\tif not ev:\n\t\treturn []\n\ttry:\n\t\tres = AlarmDashboard.alarms.getEventData(ev['id'], ev['time_ms'], ev['source'])\n\t\treturn res['props'] or []\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + }, + "params.event": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 580, + "width": 580 + } + }, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblTitle" + }, + "position": { + "basis": "auto", + "grow": 1, + "shrink": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['label'] or value['source'] or 'Event Detail'\n\texcept:\n\t\treturn 'Event Detail'", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.fontSize": { + "binding": { + "config": { + "path": "this.props.text" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tn = len(value or '')\n\texcept:\n\t\tn = 0\n\tif n <= 24:\n\t\treturn '28px'\n\tsize = int(28.0 * 24 / n)\n\tif size < 12:\n\t\tsize = 12\n\treturn '%dpx' % size", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Big", + "minWidth": "0px", + "whiteSpace": "nowrap" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "chipState" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\treturn value is not None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ts = value['state']\n\texcept:\n\t\ts = None\n\tif s == 'ack':\n\t\treturn 'AlarmDashboard/State/Acked'\n\tif s == 'clear':\n\t\treturn 'AlarmDashboard/State/Cleared'\n\treturn 'AlarmDashboard/State/Active'", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['state_label'] or ''\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "borderRadius": "10px", + "fontSize": "11px", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "chipPriority" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\treturn value is not None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tp = value['priority_name']\n\texcept:\n\t\tp = None\n\tif p not in ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']:\n\t\tp = 'Diagnostic'\n\treturn 'AlarmDashboard/Priority/' + p", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['priority_name'] or ''\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "borderRadius": "10px", + "fontSize": "11px", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "headerRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "meta": { + "name": "k" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "Time" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "v" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['time_label'] or AlarmDashboard.fmt.day_clock(value['time_ms'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": {}, + "type": "ia.display.label" + } + ], + "meta": { + "name": "rowTime" + }, + "props": { + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "k" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "Source" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "v" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['source'] or '--'\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "overflowWrap": "anywhere" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "rowSource" + }, + "props": { + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "k" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "Area" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "v" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['area'] or '--'\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": {}, + "type": "ia.display.label" + } + ], + "meta": { + "name": "rowArea" + }, + "props": { + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "k" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "Ack by" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "v" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['ack_user'] or '--'\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": {}, + "type": "ia.display.label" + } + ], + "meta": { + "name": "rowAck" + }, + "props": { + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "cardCore" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblPropsTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Event properties" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "tblProps" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.data": { + "binding": { + "config": { + "path": "view.custom.props" + }, + "transforms": [ + { + "code": "\tout = []\n\tfor p in (value or []):\n\t\ttry:\n\t\t\tv = p['value']\n\t\t\tout.append({'name': '%s' % p['name'], 'value': '' if v is None else '%s' % v})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "columns": [ + { + "field": "name", + "header": { + "title": "Property" + }, + "render": "string", + "sortable": false, + "strictWidth": true, + "width": 180 + }, + { + "field": "value", + "header": { + "title": "Value" + }, + "render": "string", + "sortable": false + } + ], + "emptyMessage": { + "noData": { + "text": "No extended properties for this event" + } + }, + "pager": { + "bottom": false, + "top": false + }, + "cells": { + "style": { + "padding": "5px" + } + } + }, + "type": "ia.display.table" + } + ], + "meta": { + "name": "cardProps" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "bodyCol" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\treturn value is not None", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "emptyWrap" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\treturn value is None", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "info", + "message": "No event selected" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.closePopup('AD_EventDetail')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btnClose" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "primary": true, + "text": "Close" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "footerRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "flex-end" + }, + "type": "ia.container.flex" + } + ], + "custom": {}, + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/PageBg", + "gap": "10px", + "padding": "16px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/HealthScore/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/HealthScore/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/HealthScore/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/HealthScore/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/HealthScore/view.json new file mode 100644 index 0000000..8164b28 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/HealthScore/view.json @@ -0,0 +1,318 @@ +{ + "custom": {}, + "params": { + "health": null + }, + "propConfig": { + "params.health": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 540, + "width": 540 + } + }, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "gradeChip" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tg = value['grade']\n\texcept:\n\t\tg = None\n\treturn 'AlarmDashboard/Grade/' + (g if g in ['A', 'B', 'C', 'D', 'F'] else 'NA')", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['grade'] or 'N/A'\n\texcept:\n\t\treturn 'N/A'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "38px", + "paddingBottom": "4px", + "paddingLeft": "18px", + "paddingRight": "18px", + "paddingTop": "4px", + "textAlign": "center" + } + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "meta": { + "name": "lblTitle" + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Big" + }, + "text": "Alarm Health Score" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "lblScore" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ts = value['score']\n\texcept:\n\t\ts = None\n\tif s is None:\n\t\treturn 'No data in selected range'\n\treturn 'Overall score %s / 100' % AlarmDashboard.fmt.num(s, 1)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "titleCol" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "justify": "center" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "headerRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "14px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblSubsTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Component scores" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repSubs" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tsubs = value['subs'] or []\n\texcept:\n\t\tsubs = []\n\tout = []\n\tfor s in subs:\n\t\ttry:\n\t\t\tout.append({'key': s['key'], 'label': s['label'], 'score': s['score'], 'weight': s['weight'], 'detail': s['detail']})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "elementPosition": { + "basis": "64px", + "grow": 0, + "shrink": 0 + }, + "path": "AlarmDashboard/Components/ScoreBar", + "style": { + "overflowY": "auto" + }, + "useDefaultViewWidth": false, + "useDefaultViewHeight": false + }, + "type": "ia.display.flex-repeater" + }, + { + "meta": { + "name": "emptyWrap" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn not (value and value['subs'])\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "assessment", + "message": "No health data in the selected range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "cardSubs" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblCaption" + }, + "position": { + "grow": 1, + "shrink": 1 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "Weighted ISA-18.2 composite: rate, flood, chattering, standing, priority mix" + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.closePopup('AD_HealthScore')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btnClose" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "primary": true, + "text": "Close" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "footerRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "space-between", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "custom": {}, + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/PageBg", + "gap": "12px", + "padding": "16px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/ShiftReport/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/ShiftReport/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/ShiftReport/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/ShiftReport/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/ShiftReport/view.json new file mode 100644 index 0000000..30fb0a3 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Popups/ShiftReport/view.json @@ -0,0 +1,204 @@ +{ + "custom": { + "report": null + }, + "params": {}, + "propConfig": { + "custom.report": { + "binding": { + "config": { + "expression": "{session.custom.AlarmDashboard.query.refreshToken}" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tr = AlarmDashboard.alarms.getShiftReportText()\n\t\tif r and r['text']:\n\t\t\treturn r\n\texcept:\n\t\tpass\n\treturn {'text': 'Report unavailable', 'start_ms': 0, 'end_ms': 0, 'label': ''}", + "type": "script" + } + ], + "type": "expr" + } + } + }, + "props": { + "defaultSize": { + "height": 560, + "width": 620 + } + }, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblTitle" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.report" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['label'] or 'Shift Report'\n\texcept:\n\t\treturn 'Shift Report'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Big" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "lblWindow" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.report" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ts = value['start_ms']\n\t\te = value['end_ms']\n\texcept:\n\t\treturn ''\n\tif not s or not e:\n\t\treturn ''\n\treturn '%s - %s' % (AlarmDashboard.fmt.day_clock(s), AlarmDashboard.fmt.day_clock(e))", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "headerCol" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblText" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.report" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['text'] or 'Report unavailable'\n\texcept:\n\t\treturn 'Report unavailable'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontFamily": "monospace", + "fontSize": "12px", + "lineHeight": "1.5", + "whiteSpace": "pre-wrap" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "cardText" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "overflowY": "auto" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.closePopup('AD_ShiftReport')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btnClose" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "primary": true, + "text": "Close" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "footerRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "flex-end" + }, + "type": "ia.container.flex" + } + ], + "custom": {}, + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/PageBg", + "gap": "12px", + "padding": "16px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/FilterBar/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/FilterBar/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/FilterBar/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/FilterBar/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/FilterBar/view.json new file mode 100644 index 0000000..7f15585 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/FilterBar/view.json @@ -0,0 +1,281 @@ +{ + "custom": {}, + "params": { + "bundle": null + }, + "propConfig": { + "params.bundle": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 56, + "width": 1600 + } + }, + "root": { + "children": [ + { + "meta": { + "name": "ddPriority" + }, + "position": { + "basis": "170px", + "shrink": 0 + }, + "propConfig": { + "props.options": { + "binding": { + "config": { + "path": "view.params.bundle.meta.priorities_seen" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn [{'value': p['value'], 'label': p['label']} for p in (value or [])]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + }, + "props.value": { + "binding": { + "bidirectional": true, + "config": { + "path": "session.custom.AlarmDashboard.query.priorities" + }, + "type": "property" + } + } + }, + "props": { + "multiSelect": true, + "placeholder": { + "text": "Priority" + }, + "wrapMultiSelectValues": true, + "style": { + "maxWidth": "280px" + } + }, + "type": "ia.input.dropdown" + }, + { + "meta": { + "name": "ddArea" + }, + "position": { + "basis": "170px", + "shrink": 0 + }, + "propConfig": { + "props.options": { + "binding": { + "config": { + "path": "view.params.bundle.meta.areas" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn [{'value': a, 'label': a} for a in (value or [])]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + }, + "props.value": { + "binding": { + "bidirectional": true, + "config": { + "path": "session.custom.AlarmDashboard.query.areas" + }, + "type": "property" + } + } + }, + "props": { + "multiSelect": true, + "placeholder": { + "text": "Area" + }, + "wrapMultiSelectValues": true, + "style": { + "maxWidth": "280px" + } + }, + "type": "ia.input.dropdown" + }, + { + "meta": { + "name": "ddState" + }, + "position": { + "basis": "170px", + "shrink": 0 + }, + "propConfig": { + "props.value": { + "binding": { + "bidirectional": true, + "config": { + "path": "session.custom.AlarmDashboard.query.states" + }, + "type": "property" + } + } + }, + "props": { + "multiSelect": true, + "options": [ + { + "label": "Active", + "value": "active" + }, + { + "label": "Cleared", + "value": "cleared" + }, + { + "label": "Acked", + "value": "acked" + }, + { + "label": "Unacked", + "value": "unacked" + } + ], + "placeholder": { + "text": "State" + }, + "wrapMultiSelectValues": true, + "style": { + "maxWidth": "280px" + } + }, + "type": "ia.input.dropdown" + }, + { + "meta": { + "name": "txtSearch" + }, + "position": { + "basis": "240px", + "shrink": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "bidirectional": true, + "config": { + "path": "session.custom.AlarmDashboard.query.search" + }, + "type": "property" + } + } + }, + "props": { + "deferUpdates": true, + "placeholder": "Search source/label..." + }, + "type": "ia.input.text-field" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tq = self.session.custom.AlarmDashboard.query\n\ttry:\n\t\tq.priorities = list(self.getSibling('ddPriority').props.value or [])\n\t\tq.areas = list(self.getSibling('ddArea').props.value or [])\n\t\tq.states = list(self.getSibling('ddState').props.value or [])\n\t\tq.search = str(self.getSibling('txtSearch').props.text or '')\n\texcept:\n\t\tpass\n\tq.refreshToken = q.refreshToken + 1" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btnApply" + }, + "position": { + "basis": "80px", + "shrink": 0 + }, + "props": { + "primary": true, + "text": "Apply" + }, + "type": "ia.input.button" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tq = self.session.custom.AlarmDashboard.query\n\tq.priorities = []\n\tq.areas = []\n\tq.states = []\n\tq.search = ''\n\ttry:\n\t\tself.getSibling('ddPriority').props.value = []\n\t\tself.getSibling('ddArea').props.value = []\n\t\tself.getSibling('ddState').props.value = []\n\t\tself.getSibling('txtSearch').props.text = ''\n\texcept:\n\t\tpass\n\tq.refreshToken = q.refreshToken + 1" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btnClear" + }, + "position": { + "basis": "80px", + "shrink": 0 + }, + "props": { + "primary": false, + "text": "Clear" + }, + "type": "ia.input.button" + }, + { + "meta": { + "name": "lblEventCount" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tn = value['meta']['event_count']\n\texcept:\n\t\tn = None\n\tif n is None:\n\t\treturn ''\n\treturn '%s events in range' % AlarmDashboard.fmt.num(n)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "textStyle": { + "textAlign": "right" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "alignItems": "center", + "direction": "row", + "style": { + "classes": "AlarmDashboard/Toolbar", + "gap": "8px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/Header/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/Header/resource.json new file mode 100644 index 0000000..8fcbc6e --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/Header/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "ad9c36584854ed67b341f681e7992b6264caf658ed334102fa088a1cc84012fd", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:47:54Z" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/Header/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/Header/view.json new file mode 100644 index 0000000..8449056 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Shell/Header/view.json @@ -0,0 +1,697 @@ +{ + "custom": {}, + "params": {}, + "propConfig": { + "params.bundle": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 165, + "width": 1600 + } + }, + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblTitle" + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Big" + }, + "text": "Alarm Analysis" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "lblSubtitle" + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "ISA-18.2 Alarm Performance" + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "titles" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column", + "justify": "center" + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "chipTruncated" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['meta']['truncated'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "color": "#E8883A" + }, + "text": "data truncated \u2014 narrow the range" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "chipError" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['meta']['error'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['meta']['error'] or ''\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "color": "#D64550" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "chips" + }, + "position": { + "grow": 1 + }, + "props": { + "alignItems": "center", + "justify": "flex-end", + "style": { + "gap": "8px", + "paddingRight": "12px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tspans = {'4h': 4, '8h': 8, '24h': 24, '7d': 168}\n\tpreset = event.value\n\tif preset in spans:\n\t\tq = self.session.custom.AlarmDashboard.query\n\t\tnow = system.date.toMillis(system.date.now())\n\t\tq.endMs = now\n\t\tq.startMs = now - spans[preset] * 3600 * 1000\n\t\tq.refreshToken = q.refreshToken + 1" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "ddRange" + }, + "position": { + "basis": "150px", + "shrink": 0 + }, + "propConfig": { + "props.value": { + "binding": { + "config": { + "path": "session.custom.AlarmDashboard.query.rangePreset" + }, + "type": "property" + } + } + }, + "props": { + "options": [ + { + "label": "Last 4 hours", + "value": "4h" + }, + { + "label": "Last 8 hours", + "value": "8h" + }, + { + "label": "Last 24 hours", + "value": "24h" + }, + { + "label": "Last 7 days", + "value": "7d" + } + ], + "placeholder": { + "text": "Range" + } + }, + "type": "ia.input.dropdown" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tq = self.session.custom.AlarmDashboard.query\n\tspans = {'4h': 4, '8h': 8, '24h': 24, '7d': 168}\n\tnow = system.date.toMillis(system.date.now())\n\thours = spans.get(q.rangePreset)\n\tif hours is not None:\n\t\tspan = hours * 3600 * 1000\n\telse:\n\t\tspan = q.endMs - q.startMs\n\tif span > 0:\n\t\tq.endMs = now\n\t\tq.startMs = now - span\n\tq.refreshToken = q.refreshToken + 1" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btnRefresh" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "image": { + "height": 15 + }, + "primary": false, + "text": "Refresh" + }, + "type": "ia.input.button" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.openPopup('AD_ShiftReport', 'AlarmDashboard/Popups/ShiftReport', params = {}, showCloseIcon = True, draggable = True)" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btnShiftReport" + }, + "position": { + "basis": "120px", + "shrink": 0 + }, + "props": { + "image": { + "height": 15 + }, + "text": "Shift Report" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "controls" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "rowTop" + }, + "position": { + "basis": "55px", + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "space-between", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "gauge" + }, + "position": { + "basis": "230px", + "shrink": 0 + }, + "propConfig": { + "props.params.health": { + "binding": { + "config": { + "path": "view.params.bundle.health" + }, + "type": "property" + } + } + }, + "props": { + "path": "AlarmDashboard/Components/HealthGauge", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiActivations" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['activations']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.num(value['kpis']['activations']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Activations" + }, + "path": "AlarmDashboard/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiRate" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['rate_per_hr']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.num(value['kpis']['rate_per_hr']['value'], 1)\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Rate", + "unit": "/hr" + }, + "path": "AlarmDashboard/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiActiveNow" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['active_now']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.num(value['kpis']['active_now']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Active Now" + }, + "path": "AlarmDashboard/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiUnacked" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['unacked_now']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.num(value['kpis']['unacked_now']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Unacked" + }, + "path": "AlarmDashboard/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiMtta" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['mtta_ms']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.dur(value['kpis']['mtta_ms']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "MTTA" + }, + "path": "AlarmDashboard/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiFlood" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['flood_pct']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.pct(value['kpis']['flood_pct']['value'], 1)\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Flood Time" + }, + "path": "AlarmDashboard/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiChatter" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['chatter_count']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.num(value['kpis']['chatter_count']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Chattering" + }, + "path": "AlarmDashboard/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "rowKpis" + }, + "position": { + "grow": 1 + }, + "props": { + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Toolbar", + "gap": "8px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Analysis/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Analysis/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Analysis/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Analysis/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Analysis/view.json new file mode 100644 index 0000000..e8e533d --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Analysis/view.json @@ -0,0 +1,2463 @@ +{ + "custom": {}, + "params": { + "bundle": null + }, + "propConfig": { + "params.bundle": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 760, + "width": 1600 + } + }, + "root": { + "children": [ + { + "meta": { + "name": "emptyAll" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn (not value) or value['meta']['event_count'] == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "inbox", + "message": "No alarm events in the selected range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "meta": { + "name": "hmTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Activations by Hour & Day of Week" + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblMon" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Mon" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repMon" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx, 'dow': 0, 'hour': i} for i, c in enumerate(hm['rows'][0])][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "AlarmDashboard/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "rowMon" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblTue" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Tue" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repTue" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx, 'dow': 1, 'hour': i} for i, c in enumerate(hm['rows'][1])][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "AlarmDashboard/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "rowTue" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblWed" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Wed" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repWed" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx, 'dow': 2, 'hour': i} for i, c in enumerate(hm['rows'][2])][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "AlarmDashboard/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "rowWed" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblThu" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Thu" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repThu" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx, 'dow': 3, 'hour': i} for i, c in enumerate(hm['rows'][3])][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "AlarmDashboard/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "rowThu" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblFri" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Fri" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repFri" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx, 'dow': 4, 'hour': i} for i, c in enumerate(hm['rows'][4])][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "AlarmDashboard/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "rowFri" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblSat" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Sat" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repSat" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx, 'dow': 5, 'hour': i} for i, c in enumerate(hm['rows'][5])][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "AlarmDashboard/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "rowSat" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblSun" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Sun" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repSun" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx, 'dow': 6, 'hour': i} for i, c in enumerate(hm['rows'][6])][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "AlarmDashboard/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "rowSun" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "axSpacer" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ax00" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "9px" + }, + "text": "0" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ax03" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "9px" + }, + "text": "3" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ax06" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "9px" + }, + "text": "6" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ax09" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "9px" + }, + "text": "9" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ax12" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "9px" + }, + "text": "12" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ax15" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "9px" + }, + "text": "15" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ax18" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "9px" + }, + "text": "18" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ax21" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontSize": "9px" + }, + "text": "21" + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "hourAxis" + }, + "position": { + "basis": "14px", + "shrink": 0 + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "hmGrid" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "cardHeatmap" + }, + "position": { + "basis": "0px", + "grow": 3 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "pTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Priority Mix vs ISA 80/15/5" + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "donut" + }, + "position": { + "basis": "95%" + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tp = value['priority']['pct']\n\t\treturn (p['low'] or 0) + (p['medium'] or 0) + (p['high'] or 0) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tp = value['priority']['pct']\n\t\treturn [{'label': 'Low', 'value': round(float(p['low'] or 0), 1)}, {'label': 'Medium', 'value': round(float(p['medium'] or 0), 1)}, {'label': 'High', 'value': round(float(p['high'] or 0), 1)}]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "colors": [ + "#5B9BD5", + "#E5C453", + "#E8883A" + ], + "cutoutRadius": 60, + "showLabels": false, + "showLegend": false, + "tooltipFormat": "{category}: {value.value.formatNumber('#.0')}%" + }, + "type": "ia.chart.pie" + }, + { + "meta": { + "name": "donutEmpty" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tp = value['priority']['pct']\n\t\treturn (p['low'] or 0) + (p['medium'] or 0) + (p['high'] or 0) <= 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "pie_chart", + "message": "No priority data in range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "pieWrap" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "chipLow" + }, + "position": { + "basis": "72px", + "shrink": 0 + }, + "props": { + "style": { + "borderRadius": "10px", + "classes": "AlarmDashboard/Priority/Low", + "fontSize": "11px", + "padding": "2px 8px", + "textAlign": "center" + }, + "text": "Low" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "valLow" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tpr = value['priority']\n\t\treturn '%s now vs %s target' % (AlarmDashboard.fmt.pct(pr['pct']['low'], 1), AlarmDashboard.fmt.pct(pr['target']['low'], 0))\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "targetLow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "chipMedium" + }, + "position": { + "basis": "72px", + "shrink": 0 + }, + "props": { + "style": { + "borderRadius": "10px", + "classes": "AlarmDashboard/Priority/Medium", + "fontSize": "11px", + "padding": "2px 8px", + "textAlign": "center" + }, + "text": "Medium" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "valMedium" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tpr = value['priority']\n\t\treturn '%s now vs %s target' % (AlarmDashboard.fmt.pct(pr['pct']['medium'], 1), AlarmDashboard.fmt.pct(pr['target']['medium'], 0))\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "targetMedium" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "chipHigh" + }, + "position": { + "basis": "72px", + "shrink": 0 + }, + "props": { + "style": { + "borderRadius": "10px", + "classes": "AlarmDashboard/Priority/High", + "fontSize": "11px", + "padding": "2px 8px", + "textAlign": "center" + }, + "text": "High" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "valHigh" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tpr = value['priority']\n\t\treturn '%s now vs %s target' % (AlarmDashboard.fmt.pct(pr['pct']['high'], 1), AlarmDashboard.fmt.pct(pr['target']['high'], 0))\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "targetHigh" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "targets" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "justify": "center", + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "pBody" + }, + "position": { + "grow": 1 + }, + "props": { + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "pCaption" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tsad = value['priority']['sum_abs_dev']\n\t\tif sad is None:\n\t\t\treturn ''\n\t\treturn 'Deviation from ISA 80/15/5 target: %s pts' % AlarmDashboard.fmt.num(sad, 1)\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "cardPriority" + }, + "position": { + "basis": "0px", + "grow": 2 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "8px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "rowTop" + }, + "position": { + "basis": "300px", + "grow": 1, + "shrink": 0 + }, + "props": { + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "mtTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Response Times (MTTA / MTTR)" + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblMtta" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "MTTA (mean time to acknowledge)" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "valMtta" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.dur(value['mtta_mttr']['mtta']['mean_ms'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Kpi" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "subMtta" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tm = value['mtta_mttr']['mtta']\n\t\treturn 'median %s over %s acks' % (AlarmDashboard.fmt.dur(m['median_ms']), AlarmDashboard.fmt.num(m['count']))\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "colMtta" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblMttr" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "MTTR (mean time to resolve)" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "valMttr" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn AlarmDashboard.fmt.dur(value['mtta_mttr']['mttr']['mean_ms'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Kpi" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "subMttr" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tm = value['mtta_mttr']['mttr']\n\t\treturn 'median %s over %s clears' % (AlarmDashboard.fmt.dur(m['median_ms']), AlarmDashboard.fmt.num(m['count']))\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "colMttr" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "mtHead" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "gap": "16px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "trendChart" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tfor r in (value['mtta_mttr']['trend'] or []):\n\t\t\tif r['mtta_ms'] is not None or r['mttr_ms'] is not None:\n\t\t\t\treturn True\n\texcept:\n\t\tpass\n\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.dataSources.trend": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\timport time\n\tout = []\n\ttry:\n\t\trows = value['mtta_mttr']['trend'] or []\n\texcept:\n\t\trows = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tt0 = r['t0']\n\t\t\tif t0 is None:\n\t\t\t\tcontinue\n\t\t\tts = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(t0 / 1000.0))\n\t\t\ta = r['mtta_ms']\n\t\t\tc = r['mttr_ms']\n\t\t\tout.append({'t': ts, 'mtta': None if a is None else round(a / 60000.0, 2), 'mttr': None if c is None else round(c / 60000.0, 2)})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "dataSources": {}, + "legend": { + "labels": { + "font": { + "size": 11 + } + }, + "markers": { + "height": 12, + "width": 12 + } + }, + "scrollBars": { + "horizontal": { + "enabled": false + }, + "vertical": { + "enabled": false + } + }, + "series": [ + { + "candlestick": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + } + }, + "high": { + "x": "", + "y": "" + }, + "low": { + "x": "", + "y": "" + }, + "open": { + "x": "", + "y": "" + } + }, + "column": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "height": null, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "width": null + }, + "open": { + "x": "", + "y": "" + } + }, + "data": { + "source": "trend", + "x": "t", + "y": "mtta" + }, + "defaultState": { + "visible": true + }, + "hiddenInLegend": false, + "label": { + "text": "MTTA" + }, + "line": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": true, + "fill": { + "color": "#3B7DD8", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 3, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY.formatNumber('#,###.#')} min[/]" + } + } + ], + "connect": false, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "#3B7DD8", + "dashArray": "", + "opacity": 1, + "width": 2 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "name": "mtta", + "render": "line", + "stepLine": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": true, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY.formatNumber('#,###.#')} min[/]" + }, + "visible": true, + "xAxis": "t", + "yAxis": "y", + "zIndex": 0 + }, + { + "candlestick": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + } + }, + "high": { + "x": "", + "y": "" + }, + "low": { + "x": "", + "y": "" + }, + "open": { + "x": "", + "y": "" + } + }, + "column": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "height": null, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "width": null + }, + "open": { + "x": "", + "y": "" + } + }, + "data": { + "source": "trend", + "x": "t", + "y": "mttr" + }, + "defaultState": { + "visible": true + }, + "hiddenInLegend": false, + "label": { + "text": "MTTR" + }, + "line": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": true, + "fill": { + "color": "#E8883A", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 3, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY.formatNumber('#,###.#')} min[/]" + } + } + ], + "connect": false, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "#E8883A", + "dashArray": "", + "opacity": 1, + "width": 2 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "name": "mttr", + "render": "line", + "stepLine": { + "appearance": { + "bullets": [], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY.formatNumber('#,###.#')} min[/]" + }, + "visible": true, + "xAxis": "t", + "yAxis": "y", + "zIndex": 0 + } + ], + "xAxes": [ + { + "appearance": { + "font": { + "size": 10, + "weight": 500 + }, + "grid": { + "color": "", + "dashArray": "", + "minDistance": 60, + "opacity": 0.3, + "position": 0.5 + }, + "inside": false, + "labels": { + "color": "", + "horizontalCenter": "middle", + "opacity": 1, + "rotation": 0, + "verticalCenter": "middle" + }, + "opposite": false + }, + "category": { + "break": { + "enabled": false, + "endCategory": "", + "size": 0.05, + "startCategory": "" + } + }, + "date": { + "baseInterval": { + "count": 1, + "enabled": false, + "skipEmptyPeriods": false, + "timeUnit": "hour" + }, + "break": { + "enabled": false, + "endDate": "", + "size": 0.05, + "startDate": "" + }, + "format": "M/d HH:mm", + "inputFormat": "yyyy-MM-dd HH:mm:ss", + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "inversed": false, + "label": { + "color": "", + "enabled": false, + "text": "" + }, + "name": "t", + "render": "date", + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "pointerLength": 4, + "text": "" + }, + "value": { + "break": { + "enabled": false, + "endValue": 100, + "size": 0.05, + "startValue": 0 + }, + "format": "#,###.##", + "logarithmic": false, + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "visible": true + } + ], + "yAxes": [ + { + "appearance": { + "font": { + "size": 10, + "weight": 500 + }, + "grid": { + "color": "", + "dashArray": "", + "minDistance": null, + "opacity": 0.3, + "position": 0.5 + }, + "inside": false, + "labels": { + "color": "", + "horizontalCenter": "middle", + "opacity": 1, + "rotation": 0, + "verticalCenter": "middle" + }, + "opposite": false + }, + "category": { + "break": { + "enabled": false, + "endCategory": "", + "size": 0.05, + "startCategory": "" + } + }, + "date": { + "baseInterval": { + "count": 1, + "enabled": false, + "skipEmptyPeriods": false, + "timeUnit": "hour" + }, + "break": { + "enabled": false, + "endDate": "", + "size": 0.05, + "startDate": "" + }, + "format": "M/d/yyyy HH:mm:ss", + "inputFormat": "yyyy-MM-dd kk:mm:ss", + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "inversed": false, + "label": { + "color": "", + "enabled": true, + "text": "minutes" + }, + "name": "y", + "render": "value", + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": false, + "pointerLength": 4, + "text": "" + }, + "value": { + "break": { + "enabled": false, + "endValue": 100, + "size": 0.05, + "startValue": 0 + }, + "format": "#,###.#", + "logarithmic": false, + "range": { + "max": "", + "min": 0, + "useStrict": false + } + }, + "visible": true + } + ] + }, + "type": "ia.chart.xy" + }, + { + "meta": { + "name": "trendEmpty" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tfor r in (value['mtta_mttr']['trend'] or []):\n\t\t\tif r['mtta_ms'] is not None or r['mttr_ms'] is not None:\n\t\t\t\treturn False\n\texcept:\n\t\tpass\n\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "timeline", + "message": "Not enough ack/clear data for a trend" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "cardMtta" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "flTitle" + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Flood Episodes" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "flCaption" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tp = value['floods']['pct_time_in_flood']\n\t\tif p is None:\n\t\t\treturn ''\n\t\treturn '%s of window time in flood' % AlarmDashboard.fmt.pct(p, 1)\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "flTitleRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "space-between" + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "floodTable" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['floods']['episodes'] or []) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\tout = []\n\ttry:\n\t\teps = value['floods']['episodes'] or []\n\texcept:\n\t\teps = []\n\tfor e in eps:\n\t\ttry:\n\t\t\ttop = e['top_source_label'] or ''\n\t\t\tif top and e['top_source_count'] is not None:\n\t\t\t\ttop = '%s (%s)' % (top, AlarmDashboard.fmt.num(e['top_source_count']))\n\t\t\tout.append({'start': AlarmDashboard.fmt.day_clock(e['start_ms']), 'duration': AlarmDashboard.fmt.dur(e['duration_ms']), 'events': AlarmDashboard.fmt.num(e['event_count']), 'peak': AlarmDashboard.fmt.num(e['peak_bin_count']), 'top': top or '--', 'area': e['top_area'] or '--'})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "columns": [ + { + "align": "center", + "editable": false, + "field": "start", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Start" + }, + "justify": "auto", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 1.3 + }, + { + "align": "center", + "editable": false, + "field": "duration", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Duration" + }, + "justify": "auto", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 1 + }, + { + "align": "center", + "editable": false, + "field": "events", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Events" + }, + "justify": "right", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 0.7 + }, + { + "align": "center", + "editable": false, + "field": "peak", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Peak / 10 min" + }, + "justify": "right", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 0.9 + }, + { + "align": "center", + "editable": false, + "field": "top", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Top Contributor" + }, + "justify": "auto", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 2 + }, + { + "align": "center", + "editable": false, + "field": "area", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Area" + }, + "justify": "auto", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 1 + } + ], + "pager": { + "bottom": false + }, + "rows": { + "height": 34 + }, + "selection": { + "enableRowSelection": false + }, + "cells": { + "style": { + "padding": "5px" + } + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "floodEmpty" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['floods']['episodes'] or []) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "waves", + "message": "No flood episodes in range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "cardFloods" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "8px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "rowBottom" + }, + "position": { + "basis": "340px", + "grow": 1, + "shrink": 0 + }, + "props": { + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "content" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value) and value['meta']['event_count'] > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Areas/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Areas/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Areas/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Areas/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Areas/view.json new file mode 100644 index 0000000..f973a26 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Areas/view.json @@ -0,0 +1,141 @@ +{ + "custom": {}, + "params": { + "bundle": null + }, + "propConfig": { + "params.bundle": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 700, + "width": 1600 + } + }, + "root": { + "children": [ + { + "meta": { + "name": "lblTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "fontWeight": "bold", + "letterSpacing": "1px" + }, + "text": "TOP 5 ALARMS BY AREA" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repAreas" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['areas_top'] or []) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn [{'area': a['area'], 'total': a['total'], 'top': a['top']} for a in (value['areas_top'] or [])]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "alignContent": "flex-start", + "direction": "row", + "elementPosition": { + "basis": "340px", + "grow": 1, + "shrink": 0 + }, + "path": "AlarmDashboard/Components/AreaCard", + "style": { + "gap": "10px", + "overflow": "auto" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false, + "wrap": "wrap", + "elementStyle": { + "maxWidth": "460px" + } + }, + "type": "ia.display.flex-repeater" + }, + { + "meta": { + "name": "emptyAreas" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['areas_top'] or []) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "material/layers_clear", + "message": "No alarm activity in range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "gap": "8px", + "padding": "10px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/BadActors/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/BadActors/resource.json new file mode 100644 index 0000000..012eddf --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/BadActors/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "7504c7695ab45f2fc1d0d6dc8071e96746ac068f99e878d3ca1dcf804c9eea76", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:38:41Z" + } + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/BadActors/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/BadActors/view.json new file mode 100644 index 0000000..b647bc4 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/BadActors/view.json @@ -0,0 +1,1095 @@ +{ + "custom": {}, + "params": {}, + "propConfig": { + "params.bundle": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "width": 1600 + } + }, + "root": { + "children": [ + { + "meta": { + "name": "wholeEmpty" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif not value:\n\t\t\treturn True\n\t\treturn int(value['meta']['event_count'] or 0) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "inbox", + "message": "No alarm events in the selected range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblParetoTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Top Sources by Activations" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "lblParetoCaption" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\trows = value['pareto']['rows'] or []\n\t\ttotal = value['pareto']['total_activations'] or 0\n\texcept:\n\t\trows = []\n\t\ttotal = 0\n\tif not rows or not total:\n\t\treturn 'No activations in the selected range'\n\tn = min(len(rows), 10)\n\ttry:\n\t\tcum = rows[n - 1]['cum_pct']\n\texcept:\n\t\tcum = None\n\tif cum is None:\n\t\treturn 'Top %d sources of %s activations' % (n, AlarmDashboard.fmt.num(total))\n\treturn 'Top %d sources = %s of all %s activations' % (n, AlarmDashboard.fmt.pct(cum), AlarmDashboard.fmt.num(total))", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onRowClick": { + "config": { + "script": "\ttry:\n\t\trow = event.value\n\t\tsrc = row['source']\n\t\tlbl = row['label']\n\texcept:\n\t\treturn\n\ttry:\n\t\tsrc = src['value']\n\texcept:\n\t\tpass\n\ttry:\n\t\tlbl = lbl['value']\n\texcept:\n\t\tpass\n\tif not src:\n\t\treturn\n\ttry:\n\t\tsystem.perspective.openPopup('AD_AlarmDetail', 'AlarmDashboard/Popups/AlarmDetail', params = {'source': src, 'label': lbl}, showCloseIcon = True, draggable = True)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "tblPareto" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['pareto']['rows'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "struct": { + "bundle": "{view.params.bundle}", + "focus": "{session.custom.AlarmDashboard.ui.badActorFocus}" + }, + "waitOnAll": false + }, + "transforms": [ + { + "code": "\ttry:\n\t\trows = value['bundle']['pareto']['rows'] or []\n\texcept:\n\t\trows = []\n\ttry:\n\t\ttotal = value['bundle']['pareto']['total_activations'] or 0\n\texcept:\n\t\ttotal = 0\n\ttry:\n\t\tfocus = value['focus'] or ''\n\texcept:\n\t\tfocus = ''\n\thi = {'backgroundColor': 'rgba(59, 125, 216, 0.18)', 'fontWeight': 'bold'}\n\tpri = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tout = []\n\trank = 0\n\tfor r in rows[:10]:\n\t\ttry:\n\t\t\trank = rank + 1\n\t\t\tsrc = r['source'] or ''\n\t\t\tcnt = r['count'] or 0\n\t\t\td = {}\n\t\t\td['rank'] = rank\n\t\t\td['source'] = src\n\t\t\td['label'] = r['label'] or src\n\t\t\td['area'] = r['area'] or ''\n\t\t\td['count'] = cnt\n\t\t\tif total > 0:\n\t\t\t\td['share'] = round(cnt * 100.0 / total, 1)\n\t\t\telse:\n\t\t\t\td['share'] = 0\n\t\t\ttry:\n\t\t\t\td['cum'] = AlarmDashboard.fmt.pct(r['cum_pct'])\n\t\t\texcept:\n\t\t\t\td['cum'] = '--'\n\t\t\tif focus and src == focus:\n\t\t\t\tfor k in ('label', 'area', 'count', 'cum', 'share'):\n\t\t\t\t\td[k] = {'value': d[k], 'style': hi}\n\t\t\tpname = r['priority_name'] or ''\n\t\t\tif pname in pri:\n\t\t\t\td['priority'] = {'value': pname, 'style': {'classes': 'AlarmDashboard/PrioritySoft/' + pname}}\n\t\t\telse:\n\t\t\t\td['priority'] = pname\n\t\t\tout.append(d)\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "expr-struct" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "source", + "header": { + "title": "source" + }, + "visible": false + }, + { + "field": "rank", + "header": { + "justify": "center", + "title": "#" + }, + "justify": "center", + "width": 0.5 + }, + { + "field": "label", + "header": { + "title": "Source" + }, + "width": 3 + }, + { + "field": "area", + "header": { + "title": "Area" + }, + "width": 1.5 + }, + { + "field": "priority", + "header": { + "justify": "center", + "title": "Priority" + }, + "justify": "center", + "width": 1.3 + }, + { + "field": "count", + "header": { + "justify": "right", + "title": "Count" + }, + "justify": "right", + "render": "number", + "width": 1 + }, + { + "field": "share", + "header": { + "title": "Share of Total" + }, + "number": "progress", + "progressBar": { + "bar": { + "color": "#3B7DD8" + }, + "max": 100, + "min": 0 + }, + "render": "number", + "width": 2.4 + }, + { + "field": "cum", + "header": { + "justify": "right", + "title": "Cumulative" + }, + "justify": "right", + "width": 1.3 + } + ], + "pager": { + "bottom": false + }, + "rows": { + "height": 34 + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "emptyPareto" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn not value['pareto']['rows']\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "bar_chart", + "message": "No activations in the selected range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "cardPareto" + }, + "position": { + "basis": "320px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblFocus" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "struct": { + "bundle": "{view.params.bundle}", + "focus": "{session.custom.AlarmDashboard.ui.badActorFocus}" + }, + "waitOnAll": false + }, + "transforms": [ + { + "code": "\ttry:\n\t\tsrc = value['focus'] or ''\n\texcept:\n\t\tsrc = ''\n\tif not src:\n\t\treturn ''\n\tlabel = src\n\ttry:\n\t\tb = value['bundle']\n\t\tlists = [b['pareto']['rows'], b['chattering'], b['standing']['rows'], b['fleeting']['sources']]\n\t\tfor rows in lists:\n\t\t\tfor r in (rows or []):\n\t\t\t\tif r['source'] == src:\n\t\t\t\t\tlabel = r['label'] or src\n\t\t\t\t\traise StopIteration\n\texcept:\n\t\tpass\n\treturn 'Focused: %s' % label", + "type": "script" + } + ], + "type": "expr-struct" + } + } + }, + "props": { + "style": { + "whiteSpace": "nowrap" + } + }, + "type": "ia.display.label", + "position": { + "basis": "auto", + "grow": 0, + "shrink": 0 + } + }, + { + "events": { + "dom": { + "onClick": { + "config": { + "script": "\tself.session.custom.AlarmDashboard.ui.badActorFocus = ''" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "icoClearFocus" + }, + "position": { + "basis": "20px", + "shrink": 0 + }, + "props": { + "path": "material/close", + "style": { + "cursor": "pointer", + "fontSize": "16px" + } + }, + "type": "ia.display.icon" + } + ], + "meta": { + "name": "chipFocus" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "classes": "AlarmDashboard/Chip/Flat", + "gap": "6px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "lblFocusHint" + }, + "position": { + "grow": 1 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "Matching rows are highlighted below \u2014 click the X to clear" + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "rowFocus" + }, + "position": { + "basis": "36px", + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{session.custom.AlarmDashboard.ui.badActorFocus} != \"\"" + }, + "type": "expr" + } + } + }, + "props": { + "alignItems": "center", + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblChatterTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Chattering" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "lblChatterCaption" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "Rapid-cycling alarms (>10/hr, median gap <= 2 min)" + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onRowClick": { + "config": { + "script": "\ttry:\n\t\trow = event.value\n\t\tsrc = row['source']\n\t\tlbl = row['label']\n\texcept:\n\t\treturn\n\ttry:\n\t\tsrc = src['value']\n\texcept:\n\t\tpass\n\ttry:\n\t\tlbl = lbl['value']\n\texcept:\n\t\tpass\n\tif not src:\n\t\treturn\n\ttry:\n\t\tsystem.perspective.openPopup('AD_AlarmDetail', 'AlarmDashboard/Popups/AlarmDetail', params = {'source': src, 'label': lbl}, showCloseIcon = True, draggable = True)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "tblChatter" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['chattering'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "struct": { + "bundle": "{view.params.bundle}", + "focus": "{session.custom.AlarmDashboard.ui.badActorFocus}" + }, + "waitOnAll": false + }, + "transforms": [ + { + "code": "\ttry:\n\t\trows = value['bundle']['chattering'] or []\n\texcept:\n\t\trows = []\n\ttry:\n\t\tfocus = value['focus'] or ''\n\texcept:\n\t\tfocus = ''\n\thi = {'backgroundColor': 'rgba(59, 125, 216, 0.18)', 'fontWeight': 'bold'}\n\tpri = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tout = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tsrc = r['source'] or ''\n\t\t\td = {}\n\t\t\td['source'] = src\n\t\t\td['label'] = r['label'] or src\n\t\t\td['area'] = r['area'] or ''\n\t\t\td['count'] = r['count'] or 0\n\t\t\td['per_hour'] = AlarmDashboard.fmt.num(r['per_hour'], 1)\n\t\t\tgap = r['median_gap_s']\n\t\t\tif gap is None:\n\t\t\t\td['gap'] = '--'\n\t\t\telse:\n\t\t\t\td['gap'] = AlarmDashboard.fmt.dur(gap * 1000.0)\n\t\t\tif focus and src == focus:\n\t\t\t\tfor k in ('label', 'area', 'count', 'per_hour', 'gap'):\n\t\t\t\t\td[k] = {'value': d[k], 'style': hi}\n\t\t\tpname = r['priority_name'] or ''\n\t\t\tif pname in pri:\n\t\t\t\td['priority'] = {'value': pname, 'style': {'classes': 'AlarmDashboard/PrioritySoft/' + pname}}\n\t\t\telse:\n\t\t\t\td['priority'] = pname\n\t\t\tout.append(d)\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "expr-struct" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "source", + "header": { + "title": "source" + }, + "visible": false + }, + { + "field": "label", + "header": { + "title": "Source" + }, + "width": 3 + }, + { + "field": "area", + "header": { + "title": "Area" + }, + "width": 1.6 + }, + { + "field": "priority", + "header": { + "justify": "center", + "title": "Priority" + }, + "justify": "center", + "width": 1.4 + }, + { + "field": "count", + "header": { + "justify": "right", + "title": "Count" + }, + "justify": "right", + "render": "number", + "width": 1 + }, + { + "field": "per_hour", + "header": { + "justify": "right", + "title": "Per Hr" + }, + "justify": "right", + "width": 1.1 + }, + { + "field": "gap", + "header": { + "justify": "right", + "title": "Med Gap" + }, + "justify": "right", + "width": 1.2 + } + ], + "pager": { + "bottom": false + }, + "rows": { + "height": 34 + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "emptyChatter" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn not value['chattering']\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "check_circle", + "message": "No chattering alarms in range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "cardChatter" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblStandingTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Standing / Stale" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "lblStandingCaption" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "Alarms active for more than 24 h" + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onRowClick": { + "config": { + "script": "\ttry:\n\t\trow = event.value\n\t\tsrc = row['source']\n\t\tlbl = row['label']\n\texcept:\n\t\treturn\n\ttry:\n\t\tsrc = src['value']\n\texcept:\n\t\tpass\n\ttry:\n\t\tlbl = lbl['value']\n\texcept:\n\t\tpass\n\tif not src:\n\t\treturn\n\ttry:\n\t\tsystem.perspective.openPopup('AD_AlarmDetail', 'AlarmDashboard/Popups/AlarmDetail', params = {'source': src, 'label': lbl}, showCloseIcon = True, draggable = True)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "tblStanding" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['standing']['rows'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "struct": { + "bundle": "{view.params.bundle}", + "focus": "{session.custom.AlarmDashboard.ui.badActorFocus}" + }, + "waitOnAll": false + }, + "transforms": [ + { + "code": "\ttry:\n\t\trows = value['bundle']['standing']['rows'] or []\n\texcept:\n\t\trows = []\n\ttry:\n\t\tfocus = value['focus'] or ''\n\texcept:\n\t\tfocus = ''\n\thi = {'backgroundColor': 'rgba(59, 125, 216, 0.18)', 'fontWeight': 'bold'}\n\tpri = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tout = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tsrc = r['source'] or ''\n\t\t\td = {}\n\t\t\td['source'] = src\n\t\t\td['label'] = r['label'] or src\n\t\t\td['area'] = r['area'] or ''\n\t\t\td['age'] = AlarmDashboard.fmt.dur(r['age_ms'])\n\t\t\tif r['unacked']:\n\t\t\t\td['unacked'] = {'value': 'Yes', 'style': {'color': '#D64550', 'fontWeight': 'bold'}}\n\t\t\telse:\n\t\t\t\td['unacked'] = ''\n\t\t\tif focus and src == focus:\n\t\t\t\tfor k in ('label', 'area', 'age'):\n\t\t\t\t\td[k] = {'value': d[k], 'style': hi}\n\t\t\tpname = r['priority_name'] or ''\n\t\t\tif pname in pri:\n\t\t\t\td['priority'] = {'value': pname, 'style': {'classes': 'AlarmDashboard/PrioritySoft/' + pname}}\n\t\t\telse:\n\t\t\t\td['priority'] = pname\n\t\t\tout.append(d)\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "expr-struct" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "source", + "header": { + "title": "source" + }, + "visible": false + }, + { + "field": "label", + "header": { + "title": "Source" + }, + "width": 3 + }, + { + "field": "area", + "header": { + "title": "Area" + }, + "width": 1.6 + }, + { + "field": "priority", + "header": { + "justify": "center", + "title": "Priority" + }, + "justify": "center", + "width": 1.4 + }, + { + "field": "age", + "header": { + "justify": "right", + "title": "Active For" + }, + "justify": "right", + "width": 1.3 + }, + { + "field": "unacked", + "header": { + "justify": "center", + "title": "Unacked" + }, + "justify": "center", + "width": 1.1 + } + ], + "pager": { + "bottom": false + }, + "rows": { + "height": 34 + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "emptyStanding" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn not value['standing']['rows']\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "check_circle", + "message": "No standing alarms over 24 h" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "cardStanding" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblFleetingTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Fleeting" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "lblFleetingCaption" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ttot = value['fleeting']['total']\n\texcept:\n\t\ttot = None\n\tif not tot:\n\t\treturn 'Activations lasting under 10 s'\n\treturn '%s fleeting activations (under 10 s)' % AlarmDashboard.fmt.num(tot)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + } + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onRowClick": { + "config": { + "script": "\ttry:\n\t\trow = event.value\n\t\tsrc = row['source']\n\t\tlbl = row['label']\n\texcept:\n\t\treturn\n\ttry:\n\t\tsrc = src['value']\n\texcept:\n\t\tpass\n\ttry:\n\t\tlbl = lbl['value']\n\texcept:\n\t\tpass\n\tif not src:\n\t\treturn\n\ttry:\n\t\tsystem.perspective.openPopup('AD_AlarmDetail', 'AlarmDashboard/Popups/AlarmDetail', params = {'source': src, 'label': lbl}, showCloseIcon = True, draggable = True)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "tblFleeting" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['fleeting']['sources'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "struct": { + "bundle": "{view.params.bundle}", + "focus": "{session.custom.AlarmDashboard.ui.badActorFocus}" + }, + "waitOnAll": false + }, + "transforms": [ + { + "code": "\ttry:\n\t\trows = value['bundle']['fleeting']['sources'] or []\n\texcept:\n\t\trows = []\n\ttry:\n\t\tfocus = value['focus'] or ''\n\texcept:\n\t\tfocus = ''\n\thi = {'backgroundColor': 'rgba(59, 125, 216, 0.18)', 'fontWeight': 'bold'}\n\tpri = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tout = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tsrc = r['source'] or ''\n\t\t\td = {}\n\t\t\td['source'] = src\n\t\t\td['label'] = r['label'] or src\n\t\t\td['area'] = r['area'] or ''\n\t\t\td['count'] = r['count'] or 0\n\t\t\tmed = r['median_s']\n\t\t\tif med is None:\n\t\t\t\td['median'] = '--'\n\t\t\telse:\n\t\t\t\td['median'] = AlarmDashboard.fmt.dur(med * 1000.0)\n\t\t\tif focus and src == focus:\n\t\t\t\tfor k in ('label', 'area', 'count', 'median'):\n\t\t\t\t\td[k] = {'value': d[k], 'style': hi}\n\t\t\tpname = r['priority_name'] or ''\n\t\t\tif pname in pri:\n\t\t\t\td['priority'] = {'value': pname, 'style': {'classes': 'AlarmDashboard/PrioritySoft/' + pname}}\n\t\t\telse:\n\t\t\t\td['priority'] = pname\n\t\t\tout.append(d)\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "expr-struct" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "source", + "header": { + "title": "source" + }, + "visible": false + }, + { + "field": "label", + "header": { + "title": "Source" + }, + "width": 3 + }, + { + "field": "area", + "header": { + "title": "Area" + }, + "width": 1.6 + }, + { + "field": "priority", + "header": { + "justify": "center", + "title": "Priority" + }, + "justify": "center", + "width": 1.4 + }, + { + "field": "count", + "header": { + "justify": "right", + "title": "Count" + }, + "justify": "right", + "render": "number", + "width": 1 + }, + { + "field": "median", + "header": { + "justify": "right", + "title": "Med Dur" + }, + "justify": "right", + "width": 1.2 + } + ], + "pager": { + "bottom": false + }, + "rows": { + "height": 34 + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "emptyFleeting" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn not value['fleeting']['sources']\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "check_circle", + "message": "No fleeting activations in range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "cardFleeting" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "4px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "rowTables" + }, + "position": { + "basis": "280px", + "grow": 1 + }, + "props": { + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "content" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value) and int(value['meta']['event_count'] or 0) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Journal/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Journal/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Journal/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Journal/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Journal/view.json new file mode 100644 index 0000000..0918345 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Journal/view.json @@ -0,0 +1,572 @@ +{ + "custom": { + "page": 0, + "pageData": null + }, + "params": { + "bundle": null + }, + "propConfig": { + "custom.pageData": { + "binding": { + "config": { + "expression": "{session.custom.AlarmDashboard.query.refreshToken} + '|' + {session.custom.AlarmDashboard.query.startMs} + '|' + {session.custom.AlarmDashboard.query.endMs} + '|' + {session.custom.AlarmDashboard.query.filterDow} + '|' + {session.custom.AlarmDashboard.query.filterHour} + '|' + {view.custom.page}" + }, + "transforms": [ + { + "code": "\tsafe = {'rows': [], 'total': 0, 'page': 0, 'page_size': 50, 'truncated': False, 'error': None}\n\ttry:\n\t\tq = self.session.custom.AlarmDashboard.query\n\t\tif not q.startMs or not q.endMs:\n\t\t\treturn safe\n\t\ttry:\n\t\t\tpage = max(0, int(self.view.custom.page or 0))\n\t\texcept:\n\t\t\tpage = 0\n\t\treturn AlarmDashboard.alarms.getJournalPage(q.startMs, q.endMs, {'priorities': list(q.priorities), 'areas': list(q.areas), 'states': list(q.states), 'search': q.search, 'dow': q.filterDow, 'hour': q.filterHour}, page, 50)\n\texcept:\n\t\timport sys\n\t\tsafe['error'] = '%s' % (sys.exc_info()[1],)\n\t\treturn safe", + "type": "script" + } + ], + "type": "expr" + } + }, + "params.bundle": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 800, + "width": 1600 + } + }, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblTotal" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ttotal = int(value['total'] or 0)\n\texcept:\n\t\ttotal = 0\n\ttry:\n\t\ttxt = '%s events' % AlarmDashboard.fmt.num(total)\n\texcept:\n\t\ttxt = '%d events' % total\n\ttry:\n\t\tif value['truncated']:\n\t\t\ttxt = txt + ' (truncated)'\n\texcept:\n\t\tpass\n\treturn txt", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "textStyle": { + "fontWeight": "bold" + } + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "meta": { + "name": "lblTimeFilter" + }, + "position": { + "basis": "auto", + "grow": 0, + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "struct": { + "dow": "{session.custom.AlarmDashboard.query.filterDow}", + "hour": "{session.custom.AlarmDashboard.query.filterHour}" + }, + "waitOnAll": false + }, + "transforms": [ + { + "code": "\tdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']\n\ttry:\n\t\td = int(value['dow'])\n\t\th = int(value['hour'])\n\texcept:\n\t\treturn ''\n\tif d < 0 and h < 0:\n\t\treturn ''\n\tday = days[d] if 0 <= d < 7 else 'Any day'\n\tif h >= 0:\n\t\treturn 'Time filter: %s %02d:00-%02d:59' % (day, h, h)\n\treturn 'Time filter: %s' % day", + "type": "script" + } + ], + "type": "expr-struct" + } + } + }, + "props": { + "style": { + "whiteSpace": "nowrap" + } + }, + "type": "ia.display.label" + }, + { + "events": { + "dom": { + "onClick": { + "config": { + "script": "\tq = self.session.custom.AlarmDashboard.query\n\tq.filterDow = -1\n\tq.filterHour = -1" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "icoClearTimeFilter" + }, + "position": { + "basis": "20px", + "grow": 0, + "shrink": 0 + }, + "props": { + "path": "material/close", + "style": { + "cursor": "pointer", + "fontSize": "16px" + } + }, + "type": "ia.display.icon" + } + ], + "meta": { + "name": "chipTimeFilter" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{session.custom.AlarmDashboard.query.filterDow} > -1 || {session.custom.AlarmDashboard.query.filterHour} > -1" + }, + "type": "expr" + } + } + }, + "props": { + "alignItems": "center", + "style": { + "classes": "AlarmDashboard/Chip/Flat", + "gap": "6px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "lblError" + }, + "position": { + "shrink": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['error'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['error'] or ''\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted", + "color": "#D64550" + } + }, + "type": "ia.display.label" + }, + { + "children": [], + "meta": { + "name": "spacer" + }, + "position": { + "grow": 1 + }, + "props": {}, + "type": "ia.container.flex" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\ttry:\n\t\tp = int(self.view.custom.page or 0)\n\texcept:\n\t\tp = 0\n\tif p > 0:\n\t\tself.view.custom.page = p - 1" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btnPrev" + }, + "position": { + "basis": "80px", + "shrink": 0 + }, + "propConfig": { + "props.enabled": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn int(value['page'] or 0) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "primary": false, + "text": "Prev" + }, + "type": "ia.input.button" + }, + { + "meta": { + "name": "lblPage" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ttotal = int(value['total'] or 0)\n\t\tsize = int(value['page_size'] or 50)\n\t\tpage = int(value['page'] or 0)\n\texcept:\n\t\ttotal = 0\n\t\tsize = 50\n\t\tpage = 0\n\tif size < 1:\n\t\tsize = 50\n\tpages = (total + size - 1) // size\n\tif pages < 1:\n\t\tpages = 1\n\ttry:\n\t\treturn 'Page %s of %s' % (AlarmDashboard.fmt.num(page + 1), AlarmDashboard.fmt.num(pages))\n\texcept:\n\t\treturn 'Page %d of %d' % (page + 1, pages)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "textStyle": { + "textAlign": "center" + } + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\ttry:\n\t\tp = int(self.view.custom.page or 0)\n\texcept:\n\t\tp = 0\n\ttry:\n\t\td = self.view.custom.pageData\n\t\ttotal = int(d['total'] or 0)\n\t\tsize = int(d['page_size'] or 50)\n\texcept:\n\t\ttotal = 0\n\t\tsize = 50\n\tif size < 1:\n\t\tsize = 50\n\tpages = (total + size - 1) // size\n\tif pages < 1:\n\t\tpages = 1\n\tif p < pages - 1:\n\t\tself.view.custom.page = p + 1" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btnNext" + }, + "position": { + "basis": "80px", + "shrink": 0 + }, + "propConfig": { + "props.enabled": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ttotal = int(value['total'] or 0)\n\t\tsize = int(value['page_size'] or 50)\n\t\tpage = int(value['page'] or 0)\n\texcept:\n\t\treturn False\n\tif size < 1:\n\t\tsize = 50\n\treturn (page + 1) * size < total", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "primary": false, + "text": "Next" + }, + "type": "ia.input.button" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\ttry:\n\t\tq = self.session.custom.AlarmDashboard.query\n\t\tcsv = AlarmDashboard.alarms.journalCsv(q.startMs, q.endMs, {'priorities': list(q.priorities), 'areas': list(q.areas), 'states': list(q.states), 'search': q.search, 'dow': q.filterDow, 'hour': q.filterHour})\n\t\tsystem.perspective.download('alarm_journal.csv', csv)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "btnExport" + }, + "position": { + "basis": "110px", + "shrink": 0 + }, + "props": { + "primary": true, + "text": "Export CSV" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "toolbar" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "classes": "AlarmDashboard/Toolbar", + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "events": { + "component": { + "onRowClick": { + "config": { + "script": "\trow = event.value\n\tevt = {}\n\ttry:\n\t\tfor k in row:\n\t\t\tv = row[k]\n\t\t\ttry:\n\t\t\t\tevt[k] = v['value']\n\t\t\texcept:\n\t\t\t\tevt[k] = v\n\texcept:\n\t\tevt = {}\n\ttry:\n\t\tif evt:\n\t\t\tsystem.perspective.openPopup('AD_EventDetail', 'AlarmDashboard/Popups/EventDetail', params = {'event': evt}, showCloseIcon = True, draggable = True)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "tblJournal" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['rows'] or []) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\tstate_cls = {'active': 'AlarmDashboard/State/Active', 'ack': 'AlarmDashboard/State/Acked', 'clear': 'AlarmDashboard/State/Cleared'}\n\tprio_cls = {'Diagnostic': 'AlarmDashboard/PrioritySoft/Diagnostic', 'Low': 'AlarmDashboard/PrioritySoft/Low', 'Medium': 'AlarmDashboard/PrioritySoft/Medium', 'High': 'AlarmDashboard/PrioritySoft/High', 'Critical': 'AlarmDashboard/PrioritySoft/Critical'}\n\ttry:\n\t\trows = value['rows'] or []\n\texcept:\n\t\trows = []\n\tout = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tsc = state_cls.get(r['state'], '')\n\t\t\tpc = prio_cls.get(r['priority_name'], '')\n\t\t\tout.append({'time_label': r['time_label'] or '', 'state_label': {'value': r['state_label'] or '', 'style': {'classes': sc}}, 'priority_name': {'value': r['priority_name'] or '', 'style': {'classes': pc}}, 'label': r['label'] or '', 'area': r['area'] or '', 'ack_user': r['ack_user'] or '', 'id': r['id'] or '', 'time_ms': r['time_ms'] or 0, 'source': r['source'] or '', 'state': r['state'] or '', 'priority': r['priority']})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "columns": [ + { + "field": "time_label", + "header": { + "title": "Time" + }, + "sortable": false, + "strictWidth": true, + "width": 150 + }, + { + "field": "state_label", + "header": { + "title": "State" + }, + "justify": "center", + "sortable": false, + "strictWidth": true, + "width": 110 + }, + { + "field": "priority_name", + "header": { + "title": "Priority" + }, + "justify": "center", + "sortable": false, + "strictWidth": true, + "width": 110 + }, + { + "field": "label", + "header": { + "title": "Alarm" + }, + "sortable": false + }, + { + "field": "area", + "header": { + "title": "Area" + }, + "sortable": false, + "strictWidth": true, + "width": 160 + }, + { + "field": "ack_user", + "header": { + "title": "Ack By" + }, + "sortable": false, + "strictWidth": true, + "width": 120 + }, + { + "field": "id", + "visible": false + }, + { + "field": "time_ms", + "visible": false + }, + { + "field": "source", + "visible": false + }, + { + "field": "state", + "visible": false + }, + { + "field": "priority", + "visible": false + } + ], + "pager": { + "bottom": false, + "top": false + }, + "cells": { + "style": { + "padding": "5px" + } + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "empty" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['rows'] or []) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "search", + "message": "No events match the current filters" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "cardTable" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Overview/resource.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Overview/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Overview/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Overview/view.json b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Overview/view.json new file mode 100644 index 0000000..af8af12 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/com.inductiveautomation.perspective/views/AlarmDashboard/Tabs/Overview/view.json @@ -0,0 +1,1807 @@ +{ + "custom": {}, + "params": { + "bundle": null + }, + "propConfig": { + "params.bundle": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 900, + "width": 1600 + } + }, + "root": { + "children": [ + { + "meta": { + "name": "emptyWhole" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn not (int(value['meta']['event_count']) > 0)\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "inbox", + "message": "No alarm events in the selected range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "meta": { + "name": "repPriority" + }, + "position": { + "basis": "120px", + "shrink": 0 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\trows = []\n\ttry:\n\t\tfor r in (value['priority']['raw'] or []):\n\t\t\ttry:\n\t\t\t\trows.append({'name': r['name'], 'level': int(r['level']), 'count': int(r['count'])})\n\t\t\texcept:\n\t\t\t\tpass\n\texcept:\n\t\trows = []\n\trows.sort(key=lambda r: -r['level'])\n\ttotal = 0\n\tfor r in rows:\n\t\ttotal = total + r['count']\n\tout = []\n\tfor r in rows:\n\t\tif total > 0:\n\t\t\tp = 100.0 * r['count'] / total\n\t\telse:\n\t\t\tp = 0.0\n\t\tout.append({'priority_name': r['name'], 'count': r['count'], 'pct': p})\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "row", + "elementPosition": { + "basis": "0px", + "grow": 1, + "shrink": 1 + }, + "path": "AlarmDashboard/Components/PriorityCard", + "style": { + "gap": "10px", + "overflow": "visible" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + }, + { + "children": [ + { + "meta": { + "name": "kpiActiveNow" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn value['kpis']['active_now']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn AlarmDashboard.fmt.num(value['kpis']['active_now']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Active Now" + }, + "path": "AlarmDashboard/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiUnacked" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn value['kpis']['unacked_now']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn AlarmDashboard.fmt.num(value['kpis']['unacked_now']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Unacked" + }, + "path": "AlarmDashboard/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiShelved" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn value['kpis']['shelved_now']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn AlarmDashboard.fmt.num(value['kpis']['shelved_now']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Shelved" + }, + "path": "AlarmDashboard/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "kpiStanding" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn value['kpis']['standing_count']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn AlarmDashboard.fmt.num(value['kpis']['standing_count']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Standing" + }, + "path": "AlarmDashboard/Components/KpiCard" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "rowStatus" + }, + "position": { + "basis": "104px", + "shrink": 0 + }, + "props": { + "alignItems": "stretch", + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblRateTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Alarm Rate Timeline" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "lblRateCaption" + }, + "position": { + "grow": 1 + }, + "props": { + "style": { + "classes": "AlarmDashboard/Text/Muted" + }, + "text": "flood bins in red \u00b7 dashed line = ISA target rate", + "textStyle": { + "textAlign": "right" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "rateHeader" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "chartRate" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn len(value['rate']['bins']) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.dataSources.rate": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\trate = value['rate']\n\t\tbins = rate['bins'] or []\n\texcept:\n\t\treturn []\n\ttry:\n\t\ttarget = float(rate['target_per_bin'])\n\texcept:\n\t\ttarget = None\n\ttry:\n\t\tuse_day = float(value['meta']['window_hours']) > 24\n\texcept:\n\t\tuse_day = False\n\tout = []\n\tfor b in bins:\n\t\ttry:\n\t\t\tt0 = b['t0']\n\t\t\tc = int(b['count'])\n\t\texcept:\n\t\t\tcontinue\n\t\ttry:\n\t\t\tflood = bool(b['flood'])\n\t\texcept:\n\t\t\tflood = False\n\t\tif use_day:\n\t\t\tlbl = AlarmDashboard.fmt.day_clock(t0)\n\t\telse:\n\t\t\tlbl = AlarmDashboard.fmt.clock(t0)\n\t\trow = {'t': lbl, 'count_base': (0 if flood else c), 'count_flood': (c if flood else 0)}\n\t\tif target is not None:\n\t\t\trow['target'] = target\n\t\tout.append(row)\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "background": { + "color": "", + "gradient": { + "colors": [], + "direction": "linear", + "rotation": 0 + }, + "opacity": 1, + "render": "none" + }, + "cursor": { + "enabled": true, + "lineX": { + "enabled": true, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 0.4, + "width": 1 + } + }, + "lineY": { + "enabled": false, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 0.4, + "width": 1 + } + }, + "series": "" + }, + "enableTransitions": false, + "legend": { + "absolute": { + "x": 0, + "y": 0 + }, + "enabled": true, + "labels": { + "font": { + "color": "", + "size": 12, + "weight": 400 + }, + "text": "[bold]{name}[/]" + }, + "markers": { + "cornerRadius": { + "bottomLeft": 0, + "bottomRight": 0, + "topLeft": 0, + "topRight": 0 + }, + "enabled": true, + "height": 12, + "icon": { + "color": "", + "height": 12, + "horizontalCenter": "left", + "path": "", + "verticalCenter": "top", + "width": 12 + }, + "mirrorLookOfSeries": true, + "stroke": { + "color": "", + "opacity": 0, + "width": 1 + }, + "width": 12 + }, + "position": "bottom" + }, + "scrollBars": { + "horizontal": { + "enabled": false, + "series": "" + }, + "vertical": { + "enabled": false, + "series": "" + } + }, + "series": [ + { + "candlestick": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + } + }, + "high": { + "x": "", + "y": "" + }, + "low": { + "x": "", + "y": "" + }, + "open": { + "x": "", + "y": "" + } + }, + "column": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "#3B7DD8", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "height": null, + "stacked": true, + "stroke": { + "color": "", + "opacity": 0, + "width": 1 + }, + "width": 80 + }, + "open": { + "x": "", + "y": "" + } + }, + "data": { + "source": "rate", + "x": "t", + "y": "count_base" + }, + "defaultState": { + "visible": true + }, + "hiddenInLegend": false, + "label": { + "text": "Alarms" + }, + "line": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "name": "counts", + "render": "column", + "stepLine": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "pointerLength": 4, + "text": "{categoryX}: [bold]{valueY}[/] alarms" + }, + "visible": true, + "xAxis": "time", + "yAxis": "count", + "zIndex": 0 + }, + { + "candlestick": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + } + }, + "high": { + "x": "", + "y": "" + }, + "low": { + "x": "", + "y": "" + }, + "open": { + "x": "", + "y": "" + } + }, + "column": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "#D64550", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "height": null, + "stacked": true, + "stroke": { + "color": "", + "opacity": 0, + "width": 1 + }, + "width": 80 + }, + "open": { + "x": "", + "y": "" + } + }, + "data": { + "source": "rate", + "x": "t", + "y": "count_flood" + }, + "defaultState": { + "visible": true + }, + "hiddenInLegend": false, + "label": { + "text": "Flood bins" + }, + "line": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "name": "flood", + "render": "column", + "stepLine": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "pointerLength": 4, + "text": "{categoryX}: [bold]{valueY}[/] alarms (flood)" + }, + "visible": true, + "xAxis": "time", + "yAxis": "count", + "zIndex": 1 + }, + { + "candlestick": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + } + }, + "high": { + "x": "", + "y": "" + }, + "low": { + "x": "", + "y": "" + }, + "open": { + "x": "", + "y": "" + } + }, + "column": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "height": null, + "stacked": false, + "stroke": { + "color": "", + "opacity": 0, + "width": 1 + }, + "width": 80 + }, + "open": { + "x": "", + "y": "" + } + }, + "data": { + "source": "rate", + "x": "t", + "y": "target" + }, + "defaultState": { + "visible": true + }, + "hiddenInLegend": false, + "label": { + "text": "ISA target" + }, + "line": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "#E8883A", + "dashArray": "6,4", + "opacity": 1, + "width": 2 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "name": "target", + "render": "line", + "stepLine": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": false, + "pointerLength": 4, + "text": "" + }, + "visible": true, + "xAxis": "time", + "yAxis": "count", + "zIndex": 2 + } + ], + "style": { + "classes": "" + }, + "subtitle": { + "appearance": { + "color": "", + "font": { + "size": "", + "weight": 500 + }, + "padding": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0 + } + }, + "text": "" + }, + "title": { + "appearance": { + "color": "", + "font": { + "size": "", + "weight": 700 + }, + "padding": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0 + } + }, + "text": "" + }, + "xAxes": [ + { + "appearance": { + "font": { + "size": 11, + "weight": 500 + }, + "grid": { + "color": "", + "dashArray": "", + "minDistance": 140, + "opacity": 0, + "position": 0.5 + }, + "inside": false, + "labels": { + "color": "", + "horizontalCenter": "middle", + "opacity": 1, + "rotation": 0, + "verticalCenter": "top" + }, + "opposite": false + }, + "category": { + "break": { + "enabled": false, + "endCategory": "", + "size": 0.05, + "startCategory": "" + } + }, + "date": { + "baseInterval": { + "count": 1, + "enabled": false, + "skipEmptyPeriods": false, + "timeUnit": "hour" + }, + "break": { + "enabled": false, + "endDate": "", + "size": 0.05, + "startDate": "" + }, + "format": "M/d", + "inputFormat": "yyyy-MM-dd kk:mm:ss", + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "inversed": false, + "label": { + "color": "", + "enabled": false, + "text": "" + }, + "name": "time", + "render": "category", + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": false, + "pointerLength": 4, + "text": "" + }, + "value": { + "break": { + "enabled": false, + "endValue": 100, + "size": 0.05, + "startValue": 0 + }, + "format": "#,###.##", + "logarithmic": false, + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "visible": true + } + ], + "yAxes": [ + { + "appearance": { + "font": { + "size": 11, + "weight": 500 + }, + "grid": { + "color": "", + "dashArray": "3,3", + "minDistance": 30, + "opacity": 0.4, + "position": 0.5 + }, + "inside": false, + "labels": { + "color": "", + "horizontalCenter": "middle", + "opacity": 1, + "rotation": 0, + "verticalCenter": "middle" + }, + "opposite": false + }, + "category": { + "break": { + "enabled": false, + "endCategory": "", + "size": 0.05, + "startCategory": "" + } + }, + "date": { + "baseInterval": { + "count": 1, + "enabled": false, + "skipEmptyPeriods": false, + "timeUnit": "hour" + }, + "break": { + "enabled": false, + "endDate": "", + "size": 0.05, + "startDate": "" + }, + "format": "M/d", + "inputFormat": "yyyy-MM-dd kk:mm:ss", + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "inversed": false, + "label": { + "color": "", + "enabled": true, + "text": "Alarms / 10 min" + }, + "name": "count", + "render": "value", + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": false, + "pointerLength": 4, + "text": "" + }, + "value": { + "break": { + "enabled": false, + "endValue": 100, + "size": 0.05, + "startValue": 0 + }, + "format": "#,###", + "logarithmic": false, + "range": { + "max": "", + "min": 0, + "useStrict": false + } + }, + "visible": true + } + ], + "dataSources": { + "rate": [] + } + }, + "type": "ia.chart.xy" + }, + { + "meta": { + "name": "emptyRate" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn len(value['rate']['bins']) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "bar_chart", + "message": "No rate data in the selected range" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "cardRate" + }, + "position": { + "basis": "0px", + "grow": 3 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "6px", + "minHeight": "260px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "lblInsightsTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "What Changed" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repInsights" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\tout = []\n\ttry:\n\t\trows = value['insights'] or []\n\texcept:\n\t\trows = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tout.append({'severity': int(r['severity']), 'icon': r['icon'], 'text': r['text'], 'tab': r['tab']})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "elementPosition": { + "basis": "32px", + "grow": 0, + "shrink": 0 + }, + "path": "AlarmDashboard/Components/InsightRow", + "style": { + "gap": "4px", + "overflow": "auto" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + }, + { + "meta": { + "name": "emptyInsights" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn len(value['insights']) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "insights", + "message": "No notable changes in this window" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "cardInsights" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "lblTopTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "AlarmDashboard/CardTitle" + }, + "text": "Top 5 Sources" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "repSources" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\tout = []\n\ttry:\n\t\trows = value['top_sources'] or []\n\texcept:\n\t\trows = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tout.append({'rank': int(r['rank']), 'source': r['source'], 'label': r['label'], 'count': int(r['count']), 'pct': float(r['pct'])})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "elementPosition": { + "basis": "34px", + "grow": 0, + "shrink": 0 + }, + "path": "AlarmDashboard/Components/SourceRow", + "style": { + "gap": "4px", + "overflow": "auto" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + }, + { + "meta": { + "name": "emptyTop" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn len(value['top_sources']) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "leaderboard", + "message": "No alarm sources in this window" + }, + "path": "AlarmDashboard/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "cardTop" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "AlarmDashboard/Card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "rowBottom" + }, + "position": { + "basis": "0px", + "grow": 2 + }, + "props": { + "style": { + "gap": "12px", + "minHeight": "200px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "content" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn int(value['meta']['event_count']) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } +} diff --git a/ignition/gateway/projects/AlarmDashboard/ignition/global-props/data.bin b/ignition/gateway/projects/AlarmDashboard/ignition/global-props/data.bin new file mode 100644 index 0000000..98e0fa0 Binary files /dev/null and b/ignition/gateway/projects/AlarmDashboard/ignition/global-props/data.bin differ diff --git a/ignition/gateway/projects/AlarmDashboard/ignition/global-props/resource.json b/ignition/gateway/projects/AlarmDashboard/ignition/global-props/resource.json new file mode 100644 index 0000000..64f0add --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/ignition/global-props/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "A", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "data.bin" + ], + "attributes": { + "lastModificationSignature": "e32343f8745d6acdf441eeb5996366a03b8b8f43d6c91e30251d2889f5311492", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T16:25:30Z" + } + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/alarms/code.py b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/alarms/code.py new file mode 100644 index 0000000..e0882bf --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/alarms/code.py @@ -0,0 +1,457 @@ + +from __future__ import division, print_function + +import sys + +from AlarmDashboard import calc, fmt + +# Which alarm journal profile to read. Leave "" on a gateway with a single +# journal: the query omits the name and the gateway resolves it, so the +# dashboard binds to whatever journal is already there. Set it to the profile +# name (Gateway > Config > Alarming > Journal) when the target gateway has more +# than one journal and you need a specific one. This is the ONLY install-time +# setting - no datasource, tag provider or table name is referenced anywhere. +JOURNAL_NAME = "" + + +def _with_journal(kwargs): + """Add the configured journal profile to a queryJournal kwargs dict.""" + if JOURNAL_NAME: + kwargs["journalName"] = JOURNAL_NAME + return kwargs + + +def _journal_error(): + """Journal-query failure text, with actionable guidance for the one case a + site hits on install: a gateway with more than one journal profile rejects + an unnamed query outright ("No alarm journal profile specified").""" + text = _err_text() + try: + if not JOURNAL_NAME and "journal profile" in text: + return (text + " This gateway has more than one alarm journal:" + " set JOURNAL_NAME at the top of the AlarmDashboard.alarms" + " script to the profile this dashboard should read.") + except: + pass + return text + + +def _err_text(): + try: + return "%s" % (sys.exc_info()[1],) + except: + return "unknown error" + + +def _now_ms(): + try: + return system.date.toMillis(system.date.now()) + except: + import time + return int(time.time() * 1000) + + +def _ms_of(java_date): + try: + if java_date is None: + return None + return java_date.getTime() + except: + return None + + +def _norm_event(evt): + try: + source = str(evt.getSource() or "") + state = None + try: + if evt.getAckData() is not None: + state = "ack" + elif evt.getClearedData() is not None: + state = "clear" + elif evt.getActiveData() is not None: + state = "active" + except: + state = None + if state is None: + s = "" + try: + s = str(evt.getState() or "") + except: + pass + if s.startswith("Cleared"): + state = "clear" + elif "Acknowledged" in s and not s.startswith("Active,"): + state = "ack" + else: + state = "active" + ts = None + try: + ts = _ms_of(evt.get("eventTime")) + except: + ts = None + if ts is None: + return None + prio_raw = None + try: + p = evt.getPriority() + try: + prio_raw = int(p.ordinal()) + except: + prio_raw = str(p) + except: + prio_raw = None + lvl, name = calc.normalize_priority(prio_raw) + display_path = "" + try: + display_path = str(evt.getDisplayPath() or "") + except: + pass + is_system = source.startswith("evt:") or ":/alm:" not in source + try: + if evt.get("isSystemEvent"): + is_system = True + except: + pass + ack_user = None + try: + u = evt.get("ackUserName") + if u: + ack_user = str(u) + except: + pass + event_id = None + try: + event_id = str(evt.getId()) + except: + pass + return {"event_id": event_id, "source": source, "display_path": display_path, + "priority": lvl, "priority_name": name, "state": state, "ts": ts, + "is_system": is_system, "ack_user": ack_user} + except: + return None + + +def _fetch_journal(start_ms, end_ms, max_events, include_data=False): + """Returns (rows, dropped, truncated, error).""" + rows = [] + dropped = 0 + truncated = False + try: + kwargs = {"startDate": int(start_ms), "endDate": int(end_ms)} + if include_data: + # Required for ack attribution: without includeData the journal + # omits associated data, so evt.get("ackUserName") is always None. + kwargs["includeData"] = True + result = system.alarm.queryJournal(**_with_journal(kwargs)) + for evt in result: + r = _norm_event(evt) + if r is None: + dropped += 1 + continue + rows.append(r) + if len(rows) >= max_events: + truncated = True + break + return rows, dropped, truncated, None + except: + return rows, dropped, truncated, _journal_error() + + +def _fetch_status(): + out = [] + try: + result = system.alarm.queryStatus(state=["ActiveUnacked", "ActiveAcked"]) + for evt in result: + try: + source = str(evt.getSource() or "") + if source.startswith("evt:") or ":/alm:" not in source: + continue + active_ms = None + try: + data = evt.getActiveData() + if data is not None: + active_ms = _ms_of(data.get("eventTime")) + except: + active_ms = None + if active_ms is None: + try: + active_ms = _ms_of(evt.get("eventTime")) + except: + pass + prio_raw = None + try: + p = evt.getPriority() + try: + prio_raw = int(p.ordinal()) + except: + prio_raw = str(p) + except: + pass + lvl, name = calc.normalize_priority(prio_raw) + unacked = False + try: + unacked = "Unacknowledged" in str(evt.getState() or "") + except: + pass + display_path = "" + try: + display_path = str(evt.getDisplayPath() or "") + except: + pass + out.append({"source": source, "display_path": display_path, + "priority": lvl, "priority_name": name, + "active_ms": active_ms, "unacked": unacked}) + except: + continue + except: + pass + return out + + +def _fetch_shelved(): + try: + paths = system.alarm.getShelvedPaths() + return len(list(paths)) if paths is not None else 0 + except: + return None + + +def _clean_filters(options): + f = {"priorities": [], "areas": [], "states": [], "search": "", + "dow": -1, "hour": -1} + if not options: + return f + try: + for k in ("priorities", "areas", "states"): + v = options.get(k) + if v: + f[k] = [x for x in list(v)] + f["priorities"] = [int(x) for x in f["priorities"]] + f["areas"] = [str(x) for x in f["areas"]] + f["states"] = [str(x) for x in f["states"]] + f["search"] = str(options.get("search") or "") + except: + pass + # Journal-only heatmap drill-down: day-of-week (0=Mon) + hour, -1 = off. + for k in ("dow", "hour"): + try: + f[k] = int(options.get(k) if options.get(k) is not None else -1) + except: + f[k] = -1 + return f + + +# ---------------- Perspective entry points ---------------- + + +def getDashboardBundle(startMs, endMs, options=None): + try: + s = int(startMs) + e = int(endMs) + opts = calc.opts_merged(None) + filters = _clean_filters(options) + span = max(e - s, 1) + rows, dropped, truncated, err = _fetch_journal( + s - span, e, int(opts["max_events"])) + active_now = _fetch_status() + shelved = _fetch_shelved() + bundle = calc.build_bundle(rows, active_now, shelved, s, e, _now_ms(), + {"filters": filters}) + bundle["meta"]["dropped_rows"] = dropped + bundle["meta"]["truncated"] = truncated + if err: + bundle["meta"]["error"] = err + bundle["insights"] = fmt.insights(bundle) + try: + if bundle.get("overview"): + bundle["overview"]["insights"] = fmt.insights(bundle["overview"]) + except: + pass + return bundle + except: + try: + bundle = calc.build_bundle([], [], None, int(startMs or 0), + int(endMs or 0), _now_ms(), None) + except: + bundle = calc.build_bundle([], [], None, 0, 0, 0, None) + bundle["meta"]["error"] = _err_text() + bundle["insights"] = [] + return bundle + + +STATE_LABELS = {"active": "Active", "clear": "Cleared", "ack": "Acked"} + + +def _journal_rows(start_ms, end_ms, filters, max_rows): + rows, dropped, truncated, err = _fetch_journal(start_ms, end_ms, max_rows, + include_data=True) + out = [] + prios = set(filters.get("priorities") or []) + areas = set(filters.get("areas") or []) + states = set(filters.get("states") or []) + search = (filters.get("search") or "").strip().lower() + dow = filters.get("dow", -1) + hour = filters.get("hour", -1) + if dow >= 0 or hour >= 0: + import time + for r in rows: + if r["is_system"]: + continue + # same local-time bucketing as calc.heatmap (tm_wday 0=Mon, tm_hour) + if dow >= 0 or hour >= 0: + try: + lt = time.localtime(r["ts"] / 1000.0) + if dow >= 0 and lt.tm_wday != dow: + continue + if hour >= 0 and lt.tm_hour != hour: + continue + except: + continue + parsed = calc.parse_source(r["source"], r["display_path"]) + if prios and r["priority"] not in prios: + continue + if areas and parsed["area"] not in areas: + continue + if states: + tag = {"active": "active", "clear": "cleared", "ack": "acked"}[r["state"]] + if tag not in states and not ("unacked" in states and r["state"] == "active"): + continue + if search and search not in (r["source"] + " " + parsed["label"]).lower(): + continue + out.append({"id": r["event_id"], "time_ms": r["ts"], + "time_label": fmt.day_clock(r["ts"]), + "source": r["source"], "label": parsed["label"], + "area": parsed["area"], "state": r["state"], + "state_label": STATE_LABELS.get(r["state"], r["state"]), + "priority": r["priority"], "priority_name": r["priority_name"], + "ack_user": r["ack_user"] or ""}) + out.sort(key=lambda r: -(r["time_ms"] or 0)) + return out, truncated, err + + +def getJournalPage(startMs, endMs, filters=None, page=0, pageSize=50): + try: + f = _clean_filters(filters) + rows, truncated, err = _journal_rows(int(startMs), int(endMs), f, + int(calc.DEFAULTS["max_events"])) + page = max(0, int(page or 0)) + page_size = max(1, int(pageSize or 50)) + last_page = max(0, (len(rows) - 1) // page_size) + if page > last_page: + page = last_page + start = page * page_size + return {"rows": rows[start:start + page_size], "total": len(rows), + "page": page, "page_size": page_size, "truncated": truncated, + "error": err} + except: + return {"rows": [], "total": 0, "page": 0, "page_size": int(pageSize or 50), + "truncated": False, "error": _err_text()} + + +def journalCsv(startMs, endMs, filters=None, maxRows=10000): + try: + f = _clean_filters(filters) + rows, truncated, err = _journal_rows(int(startMs), int(endMs), f, int(maxRows)) + headers = ["time", "state", "priority", "label", "area", "source", "ack_user"] + data = [[r["time_label"], r["state_label"], r["priority_name"], r["label"], + r["area"], r["source"], r["ack_user"]] for r in rows] + ds = system.dataset.toDataSet(headers, data) + return system.dataset.toCSV(ds) + except: + return "error,%s" % _err_text().replace(",", ";") + + +def getSourceDetail(source, startMs, endMs): + try: + s = int(startMs) + e = int(endMs) + src = str(source or "") + rows, dropped, truncated, err = _fetch_journal(s, e, int(calc.DEFAULTS["max_events"]), + include_data=True) + mine = [r for r in rows if r["source"] == src] + instances, _mode = calc.correlate_instances(mine) + parsed = calc.parse_source(src, mine[0]["display_path"] if mine else "") + day_bins = calc.rate_bins(instances, s, e, {"bin_ms": 86400000, "max_bins": 400}) + daily = [{"t0": b["t0"], "count": b["count"]} for b in day_bins["bins"]] + ttas = [i["tta_ms"] for i in instances if i.get("tta_ms") is not None] + durs = [i["duration_ms"] for i in instances if i.get("duration_ms") is not None] + fleet = len([i for i in instances + if i.get("duration_ms") is not None + and i["duration_ms"] < calc.DEFAULTS["fleeting_s"] * 1000]) + ack_users = {} + for r in mine: + if r["state"] == "ack" and r.get("ack_user"): + ack_users[r["ack_user"]] = ack_users.get(r["ack_user"], 0) + 1 + top_ack = [{"user": u, "count": c} for u, c in + sorted(ack_users.items(), key=lambda kv: -kv[1])[:5]] + events = [] + for r in sorted(mine, key=lambda r: -r["ts"])[:100]: + events.append({"id": r["event_id"], "time_ms": r["ts"], + "time_label": fmt.day_clock(r["ts"]), + "state_label": STATE_LABELS.get(r["state"], r["state"]), + "priority_name": r["priority_name"], + "ack_user": r["ack_user"] or ""}) + return {"label": parsed["label"], "area": parsed["area"], "events": events, + "daily": daily, + "stats": {"count": len([i for i in instances if i["active_ms"] is not None]), + "avg_tta_ms": (sum(ttas) / len(ttas)) if ttas else None, + "avg_active_ms": (sum(durs) / len(durs)) if durs else None, + "fleeting_count": fleet, "top_ack_users": top_ack}, + "error": err} + except: + return {"label": str(source or ""), "area": "", "events": [], "daily": [], + "stats": {"count": 0, "avg_tta_ms": None, "avg_active_ms": None, + "fleeting_count": 0, "top_ack_users": []}, + "error": _err_text()} + + +def getEventData(eventId, aroundMs, source): + try: + eid = str(eventId or "") + mid = int(aroundMs) + props = [] + seen = {} + window = {"startDate": mid - 3600000, "endDate": mid + 3600000} + try: + with_data = dict(window) + with_data["includeData"] = True + result = system.alarm.queryJournal(**_with_journal(with_data)) + except: + result = system.alarm.queryJournal(**_with_journal(dict(window))) + for evt in result: + try: + if str(evt.getId()) != eid: + continue + try: + names = evt.getProperties() + except: + names = [] + for p in names or []: + try: + n = str(p) + v = evt.get(p) + if n not in seen: + seen[n] = True + props.append({"name": n, "value": str(v)}) + except: + continue + except: + continue + props.sort(key=lambda kv: kv["name"]) + return {"props": props, "error": None} + except: + return {"props": [], "error": _err_text()} + + +def getShiftReportText(shiftHours=8, anchorHour=0, options=None): + try: + bounds = fmt.shift_bounds(_now_ms(), int(shiftHours), int(anchorHour)) + s, e = bounds["previous"] + bundle = getDashboardBundle(s, e, options) + report = fmt.shift_report(bundle, bounds["label"]) + return {"text": report["text"], "start_ms": s, "end_ms": e, + "label": bounds["label"]} + except: + return {"text": "Shift report unavailable: %s" % _err_text(), + "start_ms": 0, "end_ms": 0, "label": ""} diff --git a/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/alarms/resource.json b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/alarms/resource.json new file mode 100644 index 0000000..d7b2ab7 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/alarms/resource.json @@ -0,0 +1,17 @@ +{ + "scope": "A", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "code.py" + ], + "attributes": { + "hintScope": 2, + "lastModificationSignature": "1b496e4409991a871c59e69297bc2abbb576020fbf0643f46ecd1890921c4f69", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T21:24:34Z" + } + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/calc/code.py b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/calc/code.py new file mode 100644 index 0000000..1817524 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/calc/code.py @@ -0,0 +1,904 @@ + +from __future__ import division, print_function + +DEFAULTS = { + "bin_ms": 600000, "flood_per_10min": 10, "flood_end_per_10min": 5, + "target_per_hr": 6.0, + "chatter_per_hr": 10.0, "chatter_gap_s": 120, "chatter_min_count": 5, + "fleeting_s": 10, "fleeting_min_count": 3, + "standing_hours": 24, "trend_buckets": 24, + "max_events": 50000, "max_bins": 5000, + "delta_flat_pct": 5.0, "insights_max": 5, + "priority_target": {"low": 80.0, "medium": 15.0, "high": 5.0}, + "health_weights": {"rate": 0.30, "flood": 0.25, "chatter": 0.20, + "standing": 0.15, "priority": 0.10}, + "grade_cuts": [(90, "A"), (80, "B"), (70, "C"), (60, "D"), (0, "F")], + "list_cap": 50, "active_now_cap": 200, +} + +PRIORITY_NAMES = {0: "Diagnostic", 1: "Low", 2: "Medium", 3: "High", 4: "Critical"} + +# ---------------- area grouping (site config) ---------------- +# +# Every per-area metric (Areas tab, area filter, flood "top area", per-area bad +# actors) groups on one folder of the alarm's tag path. +# +# AREA_SEGMENT picks which folder, counting from the top of the tag path: +# 0 first folder (default) -> "PS" in PS/EAS/PMP1/Alarms/Change-of-State +# 1 second folder -> "EAS" in the same path +# -2 second-to-last folder -> the folder immediately above the alarm tag +# A path with fewer folders than that falls back to its first folder. +# +# AREA_NAMES turns the raw folder into the name shown in the UI. Several codes +# mapping to the SAME name are summed into one area - that is how the pump +# station variants below roll up into a single "Pump Station". Folders that are +# not listed are shown exactly as they appear in the tag path. +AREA_SEGMENT = 0 + +AREA_NAMES = { + "EST": "Elevated Storage Tank", + "PS": "Pump Station", + "PSR": "Pump Station", + "PSR2": "Pump Station", + "PSR_STP": "Pump Station", + "LS": "Sewage Lift Station", + "VS": "Valve Site", + "FWC": "Customer Cities - Fresh Water", + "WWC": "Customer Cities - Waste Water", +} + + +def _area_index(segments): + """AREA_SEGMENT as a positive index into segments, or None if out of range.""" + if not segments: + return None + idx = AREA_SEGMENT + if idx < 0: + idx = len(segments) + idx + if 0 <= idx < len(segments): + return idx + return None + + +def area_of(segments): + """Area group key for a tag path, given its folder segments.""" + if not segments: + return "unknown" + idx = _area_index(segments) + raw = segments[idx] if idx is not None else segments[0] + return AREA_NAMES.get(raw, raw) + + +def label_of(segments, base): + """Alarm identity shown in every Source / Alarm column: the whole path after + the area folder, so it reads EAS/PMP1/Alarms/Change-of-State/active and one + site's alarm is distinguishable from another's. A display path that does not + start with the area folder is kept whole.""" + if not segments: + return "unknown" + idx = _area_index(base) + tail = segments + if idx is not None and len(segments) > idx and segments[idx] == base[idx]: + tail = segments[idx + 1:] + if not tail: + tail = segments[-1:] + return "/".join(tail) + + +def opts_merged(opts): + out = dict(DEFAULTS) + if opts: + for k in opts: + out[k] = opts[k] + return out + + +def clamp(v, lo, hi): + return max(lo, min(hi, v)) + + +def _median(values): + vs = sorted(v for v in values if v is not None) + n = len(vs) + if n == 0: + return None + if n % 2: + return float(vs[n // 2]) + return (vs[n // 2 - 1] + vs[n // 2]) / 2.0 + + +def _mean(values): + vs = [v for v in values if v is not None] + if not vs: + return None + return sum(vs) / float(len(vs)) + + +def _safe_div(a, b): + try: + if not b: + return None + return a / float(b) + except Exception: + return None + + +# ---------------- path parsing ---------------- + + +def parse_source(source, display_path): + source = source or "" + display_path = display_path or "" + tag_segments = [] + alarm_name = "" + if ":/tag:" in source: + tag_part = source.split(":/tag:", 1)[1] + tag_path = tag_part.split(":", 1)[0] + tag_segments = [s for s in tag_path.split("/") if s] + if ":/alm:" in source: + alarm_name = source.split(":/alm:", 1)[1].split(":", 1)[0] + if display_path: + segments = [s for s in display_path.split("/") if s] + elif tag_segments: + segments = list(tag_segments) + if alarm_name and (not segments or alarm_name.lower() != segments[-1].lower()): + segments.append(alarm_name) + else: + segments = [source] if source else ["unknown"] + base = tag_segments if tag_segments else segments + area = area_of(base) + label = label_of(segments, base) + return {"label": label, "area": area, "segments": segments} + + +def normalize_priority(value): + try: + iv = int(value) + if 0 <= iv <= 4: + return iv, PRIORITY_NAMES[iv] + except Exception: + pass + s = str(value).strip().lower() if value is not None else "" + for lvl in PRIORITY_NAMES: + if PRIORITY_NAMES[lvl].lower() in s and s: + return lvl, PRIORITY_NAMES[lvl] + return -1, (str(value) if value is not None else "Unknown") + + +# ---------------- correlation ---------------- + + +def _new_instance(row): + lvl, name = row.get("priority", -1), row.get("priority_name", "Unknown") + parsed = parse_source(row.get("source"), row.get("display_path")) + return {"event_id": row.get("event_id"), "source": row.get("source") or "", + "label": parsed["label"], "area": parsed["area"], + "priority": lvl, "priority_name": name, + "active_ms": None, "clear_ms": None, "ack_ms": None, + "duration_ms": None, "tta_ms": None, "open": True, + "ack_user": None, "n_events": 0} + + +def _finalize(inst): + a, c, k = inst["active_ms"], inst["clear_ms"], inst["ack_ms"] + inst["duration_ms"] = (c - a) if (a is not None and c is not None and c >= a) else None + inst["tta_ms"] = (k - a) if (a is not None and k is not None and k >= a) else None + inst["open"] = c is None + return inst + + +def correlate_instances(rows): + rows = [r for r in (rows or []) if r and not r.get("is_system") and r.get("ts") is not None] + rows.sort(key=lambda r: r["ts"]) + if not rows: + return [], "uuid" + with_id = [r for r in rows if r.get("event_id")] + mode = "uuid" if len(with_id) >= 0.9 * len(rows) else "source-sequence" + instances = [] + if mode == "uuid": + groups, order = {}, [] + for i, r in enumerate(rows): + k = r.get("event_id") or ("_noid_%d" % i) + if k not in groups: + groups[k] = [] + order.append(k) + groups[k].append(r) + for k in order: + grp = groups[k] + ident = None + for r in grp: + if r["state"] == "active": + ident = r + break + inst = _new_instance(ident or grp[0]) + inst["n_events"] = len(grp) + acts = [r["ts"] for r in grp if r["state"] == "active"] + clears = [r["ts"] for r in grp if r["state"] == "clear"] + acks = [r["ts"] for r in grp if r["state"] == "ack"] + inst["active_ms"] = min(acts) if acts else None + inst["clear_ms"] = min(clears) if clears else None + inst["ack_ms"] = min(acks) if acks else None + for r in grp: + if r.get("ack_user"): + inst["ack_user"] = r["ack_user"] + break + instances.append(_finalize(inst)) + else: + open_by_src = {} + for r in rows: + src = r.get("source") or "" + st = r.get("state") + if st == "active": + if src in open_by_src: + instances.append(_finalize(open_by_src.pop(src))) + inst = _new_instance(r) + inst["active_ms"] = r["ts"] + inst["n_events"] = 1 + open_by_src[src] = inst + else: + inst = open_by_src.get(src) + if inst is None: + inst = _new_instance(r) + open_by_src[src] = inst + inst["n_events"] = inst.get("n_events", 0) + 1 + if st == "clear" and inst["clear_ms"] is None: + inst["clear_ms"] = r["ts"] + elif st == "ack" and inst["ack_ms"] is None: + inst["ack_ms"] = r["ts"] + if r.get("ack_user"): + inst["ack_user"] = r["ack_user"] + for src in open_by_src: + instances.append(_finalize(open_by_src[src])) + instances.sort(key=lambda i: i["active_ms"] if i["active_ms"] is not None else 0) + return instances, mode + + +def partition_instances(instances, start_ms, end_ms): + prior_start = start_ms - (end_ms - start_ms) + current, prior, orphans = [], [], [] + for inst in instances or []: + a = inst.get("active_ms") + if a is None: + orphans.append(inst) + elif start_ms <= a <= end_ms: + current.append(inst) + elif prior_start <= a < start_ms: + prior.append(inst) + else: + orphans.append(inst) + return current, prior, orphans + + +def apply_filters(instances, filters): + if not filters: + return instances + prios = set(filters.get("priorities") or []) + areas = set(filters.get("areas") or []) + states = set(filters.get("states") or []) + search = (filters.get("search") or "").strip().lower() + if not prios and not areas and not states and not search: + return instances + out = [] + for inst in instances: + if prios and inst["priority"] not in prios: + continue + if areas and inst["area"] not in areas: + continue + if states: + tags = set() + if inst["open"]: + tags.add("active") + if inst["clear_ms"] is not None: + tags.add("cleared") + if inst["ack_ms"] is not None: + tags.add("acked") + else: + tags.add("unacked") + if not (states & tags): + continue + if search: + hay = (inst["source"] + " " + inst["label"]).lower() + if search not in hay: + continue + out.append(inst) + return out + + +def apply_status_filters(status_rows, filters): + """Same filter semantics as apply_filters, but for queryStatus rows + (active-now list): keys source/display_path/priority/unacked.""" + if not filters: + return status_rows + prios = set(filters.get("priorities") or []) + areas = set(filters.get("areas") or []) + states = set(filters.get("states") or []) + search = (filters.get("search") or "").strip().lower() + if not prios and not areas and not states and not search: + return status_rows + out = [] + for s in status_rows or []: + if prios and s.get("priority") not in prios: + continue + parsed = parse_source(s.get("source"), s.get("display_path")) + if areas and parsed["area"] not in areas: + continue + if states: + tags = set(["active"]) + tags.add("unacked" if s.get("unacked") else "acked") + if not (states & tags): + continue + if search: + hay = ((s.get("source") or "") + " " + parsed["label"]).lower() + if search not in hay: + continue + out.append(s) + return out + + +# ---------------- rate / floods ---------------- + + +def rate_bins(instances, start_ms, end_ms, opts=None): + o = opts_merged(opts) + bin_ms = int(o["bin_ms"]) + empty = {"bins": [], "bin_ms": bin_ms, + "target_per_bin": o["target_per_hr"] * bin_ms / 3600000.0, + "flood_threshold": 1, "max_count": 0} + if end_ms is None or start_ms is None or end_ms <= start_ms: + return empty + while (end_ms - start_ms) // bin_ms + 1 > o["max_bins"]: + bin_ms *= 2 + origin = (start_ms // bin_ms) * bin_ms + n = int((end_ms - origin) // bin_ms) + 1 + counts = {} + for inst in instances or []: + a = inst.get("active_ms") + if a is None or a < origin or a > end_ms: + continue + idx = int((a - origin) // bin_ms) + counts[idx] = counts.get(idx, 0) + 1 + flood_threshold = max(1, int(round(o["flood_per_10min"] * bin_ms / 600000.0))) + bins = [] + max_count = 0 + for i in range(n): + c = counts.get(i, 0) + max_count = max(max_count, c) + bins.append({"t0": origin + i * bin_ms, "t1": origin + (i + 1) * bin_ms, + "count": c, "flood": c > flood_threshold}) + return {"bins": bins, "bin_ms": bin_ms, + "target_per_bin": o["target_per_hr"] * bin_ms / 3600000.0, + "flood_threshold": flood_threshold, "max_count": max_count} + + +def flood_episodes(bins_result, instances, start_ms, end_ms, opts=None): + o = opts_merged(opts) + bins = (bins_result or {}).get("bins") or [] + bin_ms = (bins_result or {}).get("bin_ms") or o["bin_ms"] + thr = (bins_result or {}).get("flood_threshold") or 1 + end_thr = max(1, int(round(o["flood_end_per_10min"] * bin_ms / 600000.0))) + episodes = [] + cur = None + for b in bins: + if cur is None: + if b["count"] > thr: + cur = {"start_ms": b["t0"], "end_ms": b["t1"], "peak_bin_count": b["count"]} + else: + if b["count"] >= end_thr: + cur["end_ms"] = b["t1"] + cur["peak_bin_count"] = max(cur["peak_bin_count"], b["count"]) + else: + episodes.append(cur) + cur = None + if cur is not None: + episodes.append(cur) + for ep in episodes: + ep["duration_ms"] = ep["end_ms"] - ep["start_ms"] + src_counts = {} + area_counts = {} + total = 0 + for inst in instances or []: + a = inst.get("active_ms") + if a is not None and ep["start_ms"] <= a < ep["end_ms"]: + total += 1 + key = (inst["source"], inst["label"]) + src_counts[key] = src_counts.get(key, 0) + 1 + area_counts[inst["area"]] = area_counts.get(inst["area"], 0) + 1 + ep["event_count"] = total + if src_counts: + best = sorted(src_counts.items(), key=lambda kv: (-kv[1], kv[0][1]))[0] + ep["top_source_label"] = best[0][1] + ep["top_source_count"] = best[1] + else: + ep["top_source_label"] = "" + ep["top_source_count"] = 0 + if area_counts: + ep["top_area"] = sorted(area_counts.items(), key=lambda kv: (-kv[1], kv[0]))[0][0] + else: + ep["top_area"] = "" + span = (end_ms - start_ms) if (end_ms and start_ms and end_ms > start_ms) else 0 + pct = 0.0 + if span: + pct = 100.0 * sum(ep["duration_ms"] for ep in episodes) / span + return {"episodes": episodes, "pct_time_in_flood": pct} + + +# ---------------- detections ---------------- + + +def chattering(instances, start_ms, end_ms, opts=None): + o = opts_merged(opts) + window_h = _safe_div(end_ms - start_ms, 3600000.0) if (end_ms and start_ms) else None + if not window_h or window_h <= 0: + return [] + groups = {} + for inst in instances or []: + a = inst.get("active_ms") + if a is None: + continue + groups.setdefault(inst["source"], []).append(inst) + out = [] + for src in groups: + insts = groups[src] + count = len(insts) + if count < o["chatter_min_count"]: + continue + per_hour = count / window_h + times = sorted(i["active_ms"] for i in insts) + gaps = [times[i + 1] - times[i] for i in range(len(times) - 1)] + med_gap = _median(gaps) + if per_hour > o["chatter_per_hr"] and med_gap is not None and med_gap <= o["chatter_gap_s"] * 1000: + ref = insts[0] + out.append({"source": src, "label": ref["label"], "area": ref["area"], + "priority_name": ref["priority_name"], "count": count, + "per_hour": per_hour, "median_gap_s": med_gap / 1000.0}) + out.sort(key=lambda r: (-r["per_hour"], r["label"])) + return out[:int(o["list_cap"])] + + +def fleeting(instances, opts=None): + o = opts_merged(opts) + thresh = o["fleeting_s"] * 1000 + groups = {} + total = 0 + for inst in instances or []: + d = inst.get("duration_ms") + if d is not None and d < thresh: + total += 1 + groups.setdefault(inst["source"], []).append(inst) + sources = [] + for src in groups: + insts = groups[src] + if len(insts) < o["fleeting_min_count"]: + continue + ref = insts[0] + sources.append({"source": src, "label": ref["label"], "area": ref["area"], + "priority_name": ref["priority_name"], "count": len(insts), + "median_s": (_median([i["duration_ms"] for i in insts]) or 0) / 1000.0}) + sources.sort(key=lambda r: (-r["count"], r["label"])) + return {"total": total, "sources": sources[:int(o["list_cap"])]} + + +def standing(active_now, open_instances, now_ms, opts=None): + o = opts_merged(opts) + thresh = o["standing_hours"] * 3600000.0 + rows = [] + if active_now: + mode = "status" + for s in active_now: + a = s.get("active_ms") + age = (now_ms - a) if (a is not None and now_ms) else None + parsed = parse_source(s.get("source"), s.get("display_path")) + rows.append({"source": s.get("source") or "", "label": parsed["label"], + "area": parsed["area"], + "priority_name": s.get("priority_name", "Unknown"), + "active_ms": a, "age_ms": age, + "age_h": (age / 3600000.0) if age is not None else None, + "unacked": bool(s.get("unacked"))}) + else: + mode = "journal" + for inst in open_instances or []: + a = inst.get("active_ms") + age = (now_ms - a) if (a is not None and now_ms) else None + rows.append({"source": inst["source"], "label": inst["label"], + "area": inst["area"], "priority_name": inst["priority_name"], + "active_ms": a, "age_ms": age, + "age_h": (age / 3600000.0) if age is not None else None, + "unacked": inst.get("ack_ms") is None}) + rows.sort(key=lambda r: -(r["age_ms"] or -1)) + count = len([r for r in rows if r["age_ms"] is not None and r["age_ms"] > thresh]) + return {"mode": mode, "count": count, "rows": rows[:int(o["list_cap"])]} + + +# ---------------- MTTA / MTTR ---------------- + + +def mtta_mttr(instances, start_ms, end_ms, opts=None): + o = opts_merged(opts) + ttas = [i["tta_ms"] for i in instances or [] if i.get("tta_ms") is not None and i["tta_ms"] >= 0] + durs = [i["duration_ms"] for i in instances or [] if i.get("duration_ms") is not None and i["duration_ms"] >= 0] + result = {"mtta": {"mean_ms": _mean(ttas), "median_ms": _median(ttas), "count": len(ttas)}, + "mttr": {"mean_ms": _mean(durs), "median_ms": _median(durs), "count": len(durs)}, + "trend": []} + if not end_ms or not start_ms or end_ms <= start_ms: + return result + span = end_ms - start_ms + bin_ms = int(o["bin_ms"]) + width = max(bin_ms, ((span // int(o["trend_buckets"])) // bin_ms + 1) * bin_ms) + origin = (start_ms // width) * width + n = int((end_ms - origin) // width) + 1 + buckets = {} + for inst in instances or []: + a = inst.get("active_ms") + if a is None: + continue + idx = int((a - origin) // width) + b = buckets.setdefault(idx, {"tta": [], "dur": []}) + if inst.get("tta_ms") is not None and inst["tta_ms"] >= 0: + b["tta"].append(inst["tta_ms"]) + if inst.get("duration_ms") is not None and inst["duration_ms"] >= 0: + b["dur"].append(inst["duration_ms"]) + for i in range(n): + b = buckets.get(i, {"tta": [], "dur": []}) + result["trend"].append({"t0": origin + i * width, + "mtta_ms": _median(b["tta"]), + "mttr_ms": _median(b["dur"]), + "ack_n": len(b["tta"]), "clear_n": len(b["dur"])}) + return result + + +# ---------------- pareto / heatmap / priority ---------------- + + +def pareto(instances, opts=None): + counts = {} + refs = {} + total = 0 + for inst in instances or []: + if inst.get("active_ms") is None: + continue + total += 1 + src = inst["source"] + counts[src] = counts.get(src, 0) + 1 + if src not in refs: + refs[src] = inst + ordered = sorted(counts.items(), key=lambda kv: (-kv[1], refs[kv[0]]["label"]))[:10] + rows = [] + cum = 0 + for i, (src, c) in enumerate(ordered): + cum += c + ref = refs[src] + rows.append({"rank": i + 1, "source": src, "label": ref["label"], + "area": ref["area"], "priority_name": ref["priority_name"], + "count": c, "pct": 100.0 * c / total if total else 0.0, + "cum_pct": 100.0 * cum / total if total else 0.0}) + return {"total_activations": total, "rows": rows} + + +def top_sources(pareto_result, n=5): + return list((pareto_result or {}).get("rows") or [])[:n] + + +def heatmap(instances, opts=None): + import time as _time + rows = [[0] * 24 for _ in range(7)] + total = 0 + mx = 0 + for inst in instances or []: + a = inst.get("active_ms") + if a is None: + continue + lt = _time.localtime(a / 1000.0) + rows[lt.tm_wday][lt.tm_hour] += 1 + total += 1 + if rows[lt.tm_wday][lt.tm_hour] > mx: + mx = rows[lt.tm_wday][lt.tm_hour] + return {"rows": rows, "row_labels": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], + "max_count": mx, "total": total} + + +def area_breakdown(instances, opts=None, per_area=5): + """Top-N most active alarm sources per plant area. + Returns [{area, total, top: [{rank, source, label, priority_name, count, + pct}]}] sorted by area total desc; pct is share of the AREA's activations.""" + groups = {} + for inst in instances or []: + if inst.get("active_ms") is None: + continue + a = inst.get("area") or "unknown" + if a not in groups: + groups[a] = {"total": 0, "counts": {}, "refs": {}} + g = groups[a] + g["total"] += 1 + src = inst["source"] + g["counts"][src] = g["counts"].get(src, 0) + 1 + if src not in g["refs"]: + g["refs"][src] = inst + out = [] + for a in groups: + g = groups[a] + ordered = sorted(g["counts"].items(), + key=lambda kv: (-kv[1], g["refs"][kv[0]]["label"]))[:per_area] + top = [] + for i in range(len(ordered)): + src, c = ordered[i] + ref = g["refs"][src] + top.append({"rank": i + 1, "source": src, "label": ref["label"], + "priority_name": ref["priority_name"], "count": c, + "pct": 100.0 * c / g["total"] if g["total"] else 0.0}) + out.append({"area": a, "total": g["total"], "top": top}) + out.sort(key=lambda x: (-x["total"], x["area"])) + return out + + +def priority_distribution(instances, opts=None): + o = opts_merged(opts) + raw_counts = {} + buckets = {"low": 0, "medium": 0, "high": 0, "other": 0} + for inst in instances or []: + lvl = inst.get("priority", -1) + name = inst.get("priority_name", "Unknown") + key = (lvl, name) + raw_counts[key] = raw_counts.get(key, 0) + 1 + if lvl in (0, 1): + buckets["low"] += 1 + elif lvl == 2: + buckets["medium"] += 1 + elif lvl in (3, 4): + buckets["high"] += 1 + else: + buckets["other"] += 1 + raw = [{"name": k[1], "level": k[0], "count": raw_counts[k]} + for k in sorted(raw_counts.keys())] + bucketed = buckets["low"] + buckets["medium"] + buckets["high"] + pct = {"low": 0.0, "medium": 0.0, "high": 0.0} + sum_abs_dev = None + target = o["priority_target"] + if bucketed: + for k in pct: + pct[k] = 100.0 * buckets[k] / bucketed + sum_abs_dev = (abs(pct["low"] - target["low"]) + + abs(pct["medium"] - target["medium"]) + + abs(pct["high"] - target["high"])) + return {"raw": raw, "buckets": buckets, "pct": pct, + "target": dict(target), "sum_abs_dev": sum_abs_dev} + + +# ---------------- health / deltas ---------------- + + +def _piecewise(x, points): + if x is None: + return None + pts = sorted(points) + if x <= pts[0][0]: + return float(pts[0][1]) + if x >= pts[-1][0]: + return float(pts[-1][1]) + for i in range(len(pts) - 1): + x0, y0 = pts[i] + x1, y1 = pts[i + 1] + if x0 <= x <= x1: + if x1 == x0: + return float(y1) + return y0 + (y1 - y0) * (x - x0) / float(x1 - x0) + return float(pts[-1][1]) + + +def health_score(rate_per_hr, pct_flood, chatter_count, standing_count, + sum_abs_dev, has_activity, opts=None): + o = opts_merged(opts) + if not has_activity: + rate_s, flood_s, chatter_s = 100.0, 100.0, 100.0 + else: + rate_s = _piecewise(rate_per_hr, [(6, 100), (12, 60), (60, 0)]) + flood_s = _piecewise(pct_flood, [(0, 100), (1, 80), (5, 0)]) + chatter_s = clamp(100.0 - 25.0 * chatter_count, 0, 100) if chatter_count is not None else None + standing_s = clamp(100.0 - 20.0 * standing_count, 0, 100) if standing_count is not None else None + priority_s = clamp(100.0 - sum_abs_dev, 0, 100) if sum_abs_dev is not None else None + weights = o["health_weights"] + subs = [ + {"key": "rate", "label": "Alarm Rate", "score": rate_s, "weight": weights["rate"], + "detail": ("%.1f alarms/hr vs %.0f/hr ISA target" % (rate_per_hr, o["target_per_hr"])) + if rate_per_hr is not None else "no data"}, + {"key": "flood", "label": "Time in Flood", "score": flood_s, "weight": weights["flood"], + "detail": ("%.1f%% of window in flood (target <1%%)" % pct_flood) + if pct_flood is not None else "no data"}, + {"key": "chatter", "label": "Chattering Alarms", "score": chatter_s, "weight": weights["chatter"], + "detail": ("%d chattering alarm(s) (target 0)" % chatter_count) + if chatter_count is not None else "no data"}, + {"key": "standing", "label": "Standing Alarms", "score": standing_s, "weight": weights["standing"], + "detail": ("%d standing alarm(s) >24h (target 0)" % standing_count) + if standing_count is not None else "no data"}, + {"key": "priority", "label": "Priority Distribution", "score": priority_s, "weight": weights["priority"], + "detail": ("%.0f pts deviation from ISA 80/15/5" % sum_abs_dev) + if sum_abs_dev is not None else "no data"}, + ] + num = 0.0 + den = 0.0 + for s in subs: + if s["score"] is not None: + num += s["score"] * s["weight"] + den += s["weight"] + score = (num / den) if den else None + grade = None + if score is not None: + for cut, g in o["grade_cuts"]: + if score >= cut: + grade = g + break + if grade is None: + grade = "F" + return {"grade": grade, "score": score, "subs": subs} + + +def delta(current, prior, higher_is_worse=True, flat_pct=5.0): + d = {"value": current, "prior": prior, "delta_pct": None, "dir": "none", "good": None} + cur0 = current or 0 + pri0 = prior or 0 + if prior is None: + d["dir"] = "new" if cur0 > 0 else "none" + return d + if pri0 == 0: + d["dir"] = "new" if cur0 > 0 else "none" + return d + if current is None: + return d + dp = 100.0 * (current - prior) / float(prior) + d["delta_pct"] = dp + if abs(dp) < flat_pct: + d["dir"] = "flat" + return d + d["dir"] = "up" if dp > 0 else "down" + d["good"] = (d["dir"] == "down") == bool(higher_is_worse) + return d + + +# ---------------- the bundle ---------------- + + +def build_bundle(rows, active_now, shelved_count, start_ms, end_ms, now_ms, opts=None): + o = opts_merged(opts) + try: + start_ms = int(start_ms or 0) + end_ms = int(end_ms or 0) + now_ms = int(now_ms or 0) + except Exception: + start_ms, end_ms, now_ms = 0, 0, 0 + filters = (opts or {}).get("filters") if opts else None + instances, mode = correlate_instances(rows) + all_instances = instances + all_active_now = active_now + instances = apply_filters(instances, filters) + active_now = apply_status_filters(active_now, filters) + + # Filter dropdown options enumerate the UNfiltered data, so applying a + # filter never collapses the available choices. + areas = sorted(set([i["area"] for i in all_instances] + + [parse_source(s.get("source"), s.get("display_path"))["area"] + for s in all_active_now or []])) + prios_seen = sorted(set([(i["priority"], i["priority_name"]) + for i in all_instances])) + + bundle = _assemble(instances, active_now, shelved_count, start_ms, end_ms, + now_ms, mode, areas, prios_seen, o) + if filters_active(filters): + # Overview tab stays a plant-wide (unfiltered) summary; every other + # consumer binds the filtered top-level sections. + bundle["overview"] = _assemble(all_instances, all_active_now, + shelved_count, start_ms, end_ms, + now_ms, mode, areas, prios_seen, o) + else: + bundle["overview"] = None + return bundle + + +def filters_active(filters): + if not filters: + return False + try: + return bool(filters.get("priorities") or filters.get("areas") or + filters.get("states") or + (filters.get("search") or "").strip()) + except Exception: + return False + + +def _assemble(instances, active_now, shelved_count, start_ms, end_ms, now_ms, + mode, areas, prios_seen, o): + window_h = _safe_div(end_ms - start_ms, 3600000.0) or 0.0 + filtered_events = 0 + for i in instances: + filtered_events += i.get("n_events", 0) + current, prior, orphans = partition_instances(instances, start_ms, end_ms) + + bins = rate_bins(current, start_ms, end_ms, o) + floods = flood_episodes(bins, current, start_ms, end_ms, o) + chat = chattering(current, start_ms, end_ms, o) + fleet = fleeting(current, o) + open_current = [i for i in current if i["open"]] + stand = standing(active_now, open_current, now_ms, o) + mm = mtta_mttr(current, start_ms, end_ms, o) + par = pareto(current, o) + prio = priority_distribution(current, o) + heat = heatmap(current, o) + areas_top = area_breakdown(current, o) + + # prior mirrors (cheap subset for deltas) + p_start = start_ms - (end_ms - start_ms) + p_bins = rate_bins(prior, p_start, start_ms, o) + p_floods = flood_episodes(p_bins, prior, p_start, start_ms, o) + p_chat = chattering(prior, p_start, start_ms, o) + p_fleet = fleeting(prior, o) + p_mm = mtta_mttr(prior, p_start, start_ms, o) + + n_act = len(current) + p_act = len(prior) + rate = _safe_div(n_act, window_h) + p_rate = _safe_div(p_act, window_h) + unacked_now = len([s for s in active_now or [] if s.get("unacked")]) + flat = o["delta_flat_pct"] + + kpis = { + "activations": delta(n_act, p_act, True, flat), + "rate_per_hr": delta(rate, p_rate, True, flat), + "active_now": delta(len(active_now or []), None, True, flat), + "unacked_now": delta(unacked_now, None, True, flat), + "shelved_now": delta(shelved_count, None, True, flat), + "mtta_ms": delta(mm["mtta"]["median_ms"], p_mm["mtta"]["median_ms"], True, flat), + "mttr_ms": delta(mm["mttr"]["median_ms"], p_mm["mttr"]["median_ms"], True, flat), + "flood_pct": delta(floods["pct_time_in_flood"], p_floods["pct_time_in_flood"], True, flat), + "flood_count": delta(len(floods["episodes"]), len(p_floods["episodes"]), True, flat), + "chatter_count": delta(len(chat), len(p_chat), True, flat), + "standing_count": delta(stand["count"], None, True, flat), + "fleeting_count": delta(fleet["total"], p_fleet["total"], True, flat), + } + + has_activity = n_act > 0 + health = health_score(rate if has_activity else None, + floods["pct_time_in_flood"] if has_activity else None, + len(chat), stand["count"] if (active_now or open_current) or has_activity else None, + prio["sum_abs_dev"], has_activity, o) + if not has_activity and not (active_now or []): + health = {"grade": None, "score": None, "subs": health["subs"]} + + active_now_rows = [] + for s in (active_now or [])[:int(o["active_now_cap"])]: + parsed = parse_source(s.get("source"), s.get("display_path")) + a = s.get("active_ms") + active_now_rows.append({"source": s.get("source") or "", "label": parsed["label"], + "area": parsed["area"], + "priority_name": s.get("priority_name", "Unknown"), + "active_ms": a, + "age_ms": (now_ms - a) if a is not None else None, + "unacked": bool(s.get("unacked"))}) + + return { + "meta": {"start_ms": start_ms, "end_ms": end_ms, "prior_start_ms": p_start, + "now_ms": now_ms, "window_hours": window_h, + "event_count": filtered_events, "activation_count": n_act, + "dropped_rows": 0, "truncated": False, + "correlation_mode": mode, "standing_mode": stand["mode"], + "areas": areas, + "priorities_seen": [{"value": p[0], "label": p[1]} for p in prios_seen], + "source_count": len(set(i["source"] for i in current)), + "error": None, "opts": { + "bin_ms": bins["bin_ms"], "target_per_hr": o["target_per_hr"], + "flood_per_10min": o["flood_per_10min"], + "chatter_per_hr": o["chatter_per_hr"], + "fleeting_s": o["fleeting_s"], "standing_hours": o["standing_hours"]}}, + "kpis": kpis, + "health": health, + "insights": [], + "rate": bins, + "floods": floods, + "priority": prio, + "heatmap": heat, + "mtta_mttr": mm, + "pareto": par, + "areas_top": areas_top, + "top_sources": top_sources(par, 5), + "chattering": chat, + "fleeting": fleet, + "standing": stand, + "active_now": active_now_rows, + } diff --git a/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/calc/resource.json b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/calc/resource.json new file mode 100644 index 0000000..e9edd17 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/calc/resource.json @@ -0,0 +1,17 @@ +{ + "scope": "A", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "code.py" + ], + "attributes": { + "hintScope": 2, + "lastModificationSignature": "d6bc71bbf780509f382cf189b57f0700a614594e1af3552d7b1c9b03b8edf36c", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T21:24:39Z" + } + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/fmt/code.py b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/fmt/code.py new file mode 100644 index 0000000..d8d43bf --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/fmt/code.py @@ -0,0 +1,325 @@ + +from __future__ import division, print_function + +import time as _time + +from AlarmDashboard import calc + +ARROW_UP = u"▲" +ARROW_DOWN = u"▼" +ARROW_FLAT = u"—" + + +def dur(ms): + if ms is None: + return "--" + try: + s = int(round(ms / 1000.0)) + except Exception: + return "--" + if s < 60: + return "%ds" % s + m = s // 60 + if m < 60: + return "%dm %ds" % (m, s % 60) if s % 60 else "%dm" % m + h = m // 60 + if h < 24: + return "%dh %dm" % (h, m % 60) if m % 60 else "%dh" % h + d = h // 24 + return "%dd %dh" % (d, h % 24) if h % 24 else "%dd" % d + + +def num(v, dec=0): + if v is None: + return "--" + try: + if dec: + s = ("%%.%df" % dec) % v + whole, frac = s.split(".") + return _group(whole) + "." + frac + return _group("%d" % round(v)) + except Exception: + return str(v) + + +def _group(digits): + neg = digits.startswith("-") + if neg: + digits = digits[1:] + out = "" + while len(digits) > 3: + out = "," + digits[-3:] + out + digits = digits[:-3] + return ("-" if neg else "") + digits + out + + +def pct(v, dec=0, signed=False): + if v is None: + return "--" + try: + fmtstr = "%%+.%df%%%%" % dec if signed else "%%.%df%%%%" % dec + return fmtstr % v + except Exception: + return str(v) + + +def clock(ms): + if ms is None: + return "--" + try: + return _time.strftime("%I:%M %p", _time.localtime(ms / 1000.0)).lstrip("0") + except Exception: + return "--" + + +def day_clock(ms): + if ms is None: + return "--" + try: + return _time.strftime("%a ", _time.localtime(ms / 1000.0)) + clock(ms) + except Exception: + return "--" + + +def delta_chip(d): + if not d: + return {"arrow": ARROW_FLAT, "text": "--", "good": None} + direction = d.get("dir") + if direction == "up": + arrow = ARROW_UP + elif direction == "down": + arrow = ARROW_DOWN + else: + arrow = ARROW_FLAT + if direction == "new": + text = "new" + elif direction == "none": + text = "--" + elif d.get("delta_pct") is not None: + text = pct(d["delta_pct"], 0, True) + else: + text = "--" + return {"arrow": arrow, "text": text, "good": d.get("good")} + + +def grade_color_key(grade): + return grade if grade in ("A", "B", "C", "D", "F") else "NA" + + +# ---------------- insights ---------------- + + +def insights(bundle, opts=None): + o = calc.opts_merged(opts) + if not bundle: + return [] + out = [] + for rule in (_i_rate, _i_floods, _i_chatter, _i_standing, + _i_priority, _i_pareto, _i_fleeting): + try: + r = rule(bundle) + if r: + out.append(r) + except Exception: + pass + out.sort(key=lambda i: (i["severity"], -i.get("magnitude", 0))) + out = out[:int(o["insights_max"])] + if not out: + k = bundle.get("kpis", {}).get("rate_per_hr", {}) + rate = k.get("value") + if bundle.get("meta", {}).get("activation_count", 0) == 0: + text = "No alarm events in the selected range." + else: + text = ("No significant changes vs the prior period; rate %.1f/hr is within " + "the ISA target of %.0f/hr." % (rate or 0.0, + bundle["meta"]["opts"]["target_per_hr"])) + out = [{"severity": 3, "icon": "check_circle", "text": text, + "tab": "overview", "magnitude": 0}] + for i in out: + i.pop("magnitude", None) + return out + + +def _i_rate(b): + k = b["kpis"]["activations"] + dp = k.get("delta_pct") + cur = k.get("value") or 0 + if dp is None or cur < 10: + return None + if dp >= 25: + sev = 0 if dp >= 100 else 1 + text = ("Alarm rate up %d%% vs prior period (%s -> %s activations)" + % (round(dp), num(k.get("prior")), num(cur))) + attr = _rate_attribution(b) + if attr: + text += " - largest increase from %s" % attr + return {"severity": sev, "icon": "trending_up", "text": text + ".", + "tab": "badactors", "magnitude": dp} + if dp <= -25: + return {"severity": 3, "icon": "trending_down", + "text": "Alarm rate down %d%% vs prior period - improving." % round(-dp), + "tab": "overview", "magnitude": -dp} + return None + + +def _rate_attribution(b): + rows = b.get("pareto", {}).get("rows") or [] + if rows: + return rows[0]["label"] + return None + + +def _i_floods(b): + eps = b.get("floods", {}).get("episodes") or [] + if not eps: + return None + worst = sorted(eps, key=lambda e: -e["event_count"])[0] + total_min = sum(e["duration_ms"] for e in eps) / 60000.0 + text = ("%d flood episode(s) totaling %s; worst %s-%s (%d events, mostly %s)." + % (len(eps), dur(total_min * 60000), day_clock(worst["start_ms"]), + clock(worst["end_ms"]), worst["event_count"], + worst["top_source_label"] or worst["top_area"] or "n/a")) + return {"severity": 0, "icon": "flood", "text": text, "tab": "analysis", + "magnitude": len(eps)} + + +def _i_chatter(b): + rows = b.get("chattering") or [] + if not rows: + return None + top = rows[0] + if len(rows) == 1: + text = ("%s is chattering (%.0f re-triggers/hr, median gap %.0fs)." + % (top["label"], top["per_hour"], top["median_gap_s"])) + else: + text = ("%d alarms are chattering (>%.0f re-triggers/hr); worst is %s at %.0f/hr." + % (len(rows), b["meta"]["opts"]["chatter_per_hr"], top["label"], + top["per_hour"])) + return {"severity": 1, "icon": "repeat", "text": text, "tab": "badactors", + "magnitude": len(rows)} + + +def _i_standing(b): + st = b.get("standing") or {} + if not st.get("count"): + return None + rows = st.get("rows") or [] + oldest = rows[0] if rows else None + text = "%d alarm(s) standing >%dh" % (st["count"], b["meta"]["opts"]["standing_hours"]) + if oldest and oldest.get("age_h"): + text += "; oldest is %s at %.1f days" % (oldest["label"], oldest["age_h"] / 24.0) + return {"severity": 1, "icon": "schedule", "text": text + ".", "tab": "badactors", + "magnitude": st["count"]} + + +def _i_priority(b): + p = b.get("priority") or {} + if p.get("sum_abs_dev") is None: + return None + high = p["pct"]["high"] + target = p["target"]["high"] + if high - target >= 10: + return {"severity": 2, "icon": "priority_high", + "text": ("High/Critical alarms are %d%% of events vs %d%% ISA target - " + "priorities may be over-assigned." % (round(high), round(target))), + "tab": "analysis", "magnitude": high - target} + return None + + +def _i_pareto(b): + rows = b.get("pareto", {}).get("rows") or [] + for k, row in enumerate(rows[:3]): + if row["cum_pct"] >= 50: + n = k + 1 + return {"severity": 2, "icon": "insights", + "text": ("%d source(s) account for %d%% of all activations - start " + "with %s." % (n, round(row["cum_pct"]), rows[0]["label"])), + "tab": "badactors", "magnitude": row["cum_pct"]} + return None + + +def _i_fleeting(b): + fl = b.get("fleeting") or {} + if (fl.get("total") or 0) < 10: + return None + return {"severity": 2, "icon": "bolt", + "text": ("%d fleeting alarms (<%ds active) in range - likely deadband or " + "nuisance issues." % (fl["total"], b["meta"]["opts"]["fleeting_s"])), + "tab": "badactors", "magnitude": fl["total"]} + + +# ---------------- shift report ---------------- + + +def shift_bounds(now_ms, shift_hours=8, anchor_hour=0): + lt = _time.localtime(now_ms / 1000.0) + midnight = _time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0, + lt.tm_wday, lt.tm_yday, -1)) + anchor = midnight + anchor_hour * 3600 + now_s = now_ms / 1000.0 + if now_s < anchor: + anchor -= 86400 + shift_s = shift_hours * 3600 + k = int((now_s - anchor) // shift_s) + cur_start = anchor + k * shift_s + cur = (int(cur_start * 1000), int((cur_start + shift_s) * 1000)) + prev = (int((cur_start - shift_s) * 1000), int(cur_start * 1000)) + label = "%s - %s" % (day_clock(prev[0]), day_clock(prev[1])) + return {"current": cur, "previous": prev, "label": label} + + +def _kpi_line(name, k, formatter): + chip = delta_chip(k) + val = formatter(k.get("value")) + extra = "" if chip["text"] == "--" else " (%s %s)" % (chip["arrow"], chip["text"]) + return "%-14s %s%s" % (name + ":", val, extra) + + +def shift_report(bundle, label): + if not bundle: + return {"text": "No data.", "sections": []} + k = bundle["kpis"] + h = bundle["health"] + subs = " | ".join("%s %s" % (s["key"], "%d" % s["score"] if s["score"] is not None else "n/a") + for s in h.get("subs", [])) + sections = [] + head = ["Shift: %s" % label, + "Generated: %s" % day_clock(bundle["meta"]["now_ms"]), + "Health: %s (%s) [%s]" % (h.get("grade") or "N/A", + "%d" % h["score"] if h.get("score") is not None else "-", + subs)] + sections.append({"heading": "ALARM SHIFT REPORT", "lines": head}) + kpis = [ + _kpi_line("Activations", k["activations"], num), + _kpi_line("Rate/hr", k["rate_per_hr"], lambda v: num(v, 1)), + _kpi_line("MTTA", k["mtta_ms"], dur), + _kpi_line("MTTR", k["mttr_ms"], dur), + _kpi_line("Flood time", k["flood_pct"], lambda v: pct(v, 1)), + _kpi_line("Chattering", k["chatter_count"], num), + _kpi_line("Standing", k["standing_count"], num), + _kpi_line("Fleeting", k["fleeting_count"], num), + ] + sections.append({"heading": "KPIs (vs prior shift)", "lines": kpis}) + actors = ["#%d %-40s %6s %s" % (r["rank"], r["label"][:40], num(r["count"]), + pct(r["cum_pct"])) + for r in bundle["pareto"]["rows"][:5]] or ["none"] + sections.append({"heading": "Top actors (count, cumulative %)", "lines": actors}) + floods = ["%s - %s %s, %d events, top: %s" + % (day_clock(e["start_ms"]), clock(e["end_ms"]), dur(e["duration_ms"]), + e["event_count"], e["top_source_label"] or e["top_area"]) + for e in bundle["floods"]["episodes"]] or ["none"] + sections.append({"heading": "Flood episodes", "lines": floods}) + standing_rows = bundle["standing"]["rows"][:8] + stand = ["%-40s active %s%s" % (r["label"][:40], dur(r["age_ms"]), + " (UNACKED)" if r["unacked"] else "") + for r in standing_rows] or ["none"] + sections.append({"heading": "Standing / handover attention", "lines": stand}) + sections.append({"heading": "Insights", + "lines": [i["text"] for i in bundle.get("insights") or []] or ["none"]}) + text_parts = [] + for s in sections: + text_parts.append(s["heading"]) + text_parts.append("-" * len(s["heading"])) + text_parts.extend(s["lines"]) + text_parts.append("") + return {"text": "\n".join(text_parts), "sections": sections} diff --git a/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/fmt/resource.json b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/fmt/resource.json new file mode 100644 index 0000000..251a908 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/ignition/script-python/AlarmDashboard/fmt/resource.json @@ -0,0 +1,17 @@ +{ + "scope": "A", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "code.py" + ], + "attributes": { + "hintScope": 2, + "lastModificationSignature": "81cbca2cf7929734afa020563f85f52707e991de359d97175601faec1c13bdce", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T21:24:42Z" + } + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/AlarmDashboard/project.json b/ignition/gateway/projects/AlarmDashboard/project.json new file mode 100644 index 0000000..3e1f110 --- /dev/null +++ b/ignition/gateway/projects/AlarmDashboard/project.json @@ -0,0 +1,7 @@ +{ + "title": "Alarm Dashboard", + "description": "Alarm analysis dashboard (ISA-18.2 metrics) built on the alarm journal API. Gateway-portable: no datasource, tag or journal-name dependencies.", + "enabled": true, + "inheritable": false, + "parent": "" +} diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/session-props/props.json b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/session-props/props.json index 1026701..756a5d3 100644 --- a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/session-props/props.json +++ b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/session-props/props.json @@ -4,14 +4,14 @@ "query": { "areas": [], "endMs": 0, + "filterDow": -1, + "filterHour": -1, "priorities": [], "rangePreset": "8h", "refreshToken": 0, "search": "", "startMs": 0, - "states": [], - "filterDow": -1, - "filterHour": -1 + "states": [] }, "ui": { "badActorFocus": "", @@ -94,4 +94,4 @@ "theme": "light-cool", "timeZoneId": "America/Chicago" } -} +} \ No newline at end of file diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/session-props/resource.json b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/session-props/resource.json index 414b15b..241c5f9 100644 --- a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/session-props/resource.json +++ b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/session-props/resource.json @@ -7,10 +7,10 @@ "props.json" ], "attributes": { - "lastModificationSignature": "3d8f163b8f2bb09be0bd014e7ee7d798223ddf7b5019b1a9fa95ab63e56b2958", + "lastModificationSignature": "3859d47a0f5515c27fb6706190bb2c747a6e6a071f652dcda6a017fcc9a97c88", "lastModification": { "actor": "admin", - "timestamp": "2026-07-16T20:18:00Z" + "timestamp": "2026-08-11T20:12:19Z" } } } \ No newline at end of file diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/resource.json b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/resource.json index 276dced..b0f5f25 100644 --- a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/resource.json +++ b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/resource.json @@ -4,7 +4,14 @@ "restricted": false, "overridable": true, "files": [ - "view.json" + "view.json", + "thumbnail.png" ], - "attributes": {} -} + "attributes": { + "lastModificationSignature": "a606602b42f664df14e62d1509a4368ffc33d03863d17bc20a0bc6ed5124b07b", + "lastModification": { + "actor": "admin", + "timestamp": "2026-08-11T20:06:43Z" + } + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/view.json b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/view.json index 4c3089f..a325f6c 100644 --- a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/view.json +++ b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.perspective/views/PrimeControls/Components/AreaCard/view.json @@ -1,10 +1,6 @@ { "custom": {}, - "params": { - "area": null, - "top": null, - "total": null - }, + "params": {}, "propConfig": { "params.area": { "paramDirection": "input", @@ -34,8 +30,7 @@ "name": "lblArea" }, "position": { - "grow": 1, - "shrink": 1 + "grow": 1 }, "propConfig": { "props.text": { @@ -45,7 +40,7 @@ }, "transforms": [ { - "code": "\treturn value or ''", + "code": "\treturn value or \u0027\u0027", "type": "script" } ], @@ -79,7 +74,7 @@ }, "transforms": [ { - "code": "\ttry:\n\t\treturn '%s activations' % PrimeControls.fmt.num(int(value or 0))\n\texcept:\n\t\treturn ''", + "code": "\ttry:\n\t\treturn \u0027%s activations\u0027 % PrimeControls.fmt.num(int(value or 0))\n\texcept:\n\t\treturn \u0027\u0027", "type": "script" } ], @@ -126,7 +121,7 @@ }, "transforms": [ { - "code": "\tout = []\n\tfor r in (value or []):\n\t\ttry:\n\t\t\tout.append({'rank': int(r['rank']), 'source': r['source'], 'label': r['label'], 'count': int(r['count']), 'pct': float(r['pct'])})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "code": "\tout \u003d []\n\tfor r in (value or []):\n\t\ttry:\n\t\t\tout.append({\u0027rank\u0027: int(r[\u0027rank\u0027]), \u0027source\u0027: r[\u0027source\u0027], \u0027label\u0027: r[\u0027label\u0027], \u0027count\u0027: int(r[\u0027count\u0027]), \u0027pct\u0027: float(r[\u0027pct\u0027])})\n\t\texcept:\n\t\t\tpass\n\treturn out", "type": "script" } ], @@ -165,4 +160,4 @@ }, "type": "ia.container.flex" } -} +} \ No newline at end of file diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.vision/client-tags/data.bin b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.vision/client-tags/data.bin new file mode 100644 index 0000000..5216ec8 Binary files /dev/null and b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.vision/client-tags/data.bin differ diff --git a/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.vision/client-tags/resource.json b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.vision/client-tags/resource.json new file mode 100644 index 0000000..4aef9ac --- /dev/null +++ b/ignition/gateway/projects/PrimeBAT/com.inductiveautomation.vision/client-tags/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "C", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "data.bin" + ], + "attributes": { + "lastModificationSignature": "d0b022ab03f86f69e755008b908ddc759359b6cf2252c33bbf468a5e6e4040c3", + "lastModification": { + "actor": "admin", + "timestamp": "2026-08-11T14:44:12Z" + } + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/SimHarness/ignition/timer/AlarmSimTick/resource.json b/ignition/gateway/projects/SimHarness/ignition/timer/AlarmSimTick/resource.json index 5487d82..c5b6a70 100644 --- a/ignition/gateway/projects/SimHarness/ignition/timer/AlarmSimTick/resource.json +++ b/ignition/gateway/projects/SimHarness/ignition/timer/AlarmSimTick/resource.json @@ -10,6 +10,6 @@ "sharedThread": false, "delay": 5000, "fixedDelay": true, - "enabled": false + "enabled": true } } diff --git a/ignition/gateway/projects/alarm-analysis/README.md b/ignition/gateway/projects/alarm-analysis/README.md new file mode 100644 index 0000000..bf67e0d --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/README.md @@ -0,0 +1,118 @@ +# Alarm Analysis Dashboard + +A drop-in Ignition Perspective dashboard that turns the **alarm journal** into +actionable alarm-management metrics: alarm rate, MTTA/MTTR, flood episodes, +chattering and standing alarms, a Pareto of bad actors, a day/hour heatmap, and +an overall alarm-health score graded A–F against ISA-18.2 style targets. + +It reads the journal through `system.alarm.queryJournal` / `system.alarm.queryStatus` +only. There are **no tags, no named queries, no SQL and no database connection** — +if the gateway has an alarm journal profile with data in it, the dashboard works. + +--- + +## Requirements + +* Ignition **8.1.20+** with the **Perspective** module. +* An **alarm journal profile** that is receiving data (Gateway → Config → Alarming → + Journal). Any journal store works; the resource never talks to the database directly. +* No third-party modules, no Python packages, no gateway scripting configuration. + +## Install + +1. In the Designer, open the project you want the resource in and choose + **File → Import…**, or use **Config → Projects → Import** on the Gateway + web page to bring the download in as its own project. +2. Import the resource. Everything lands under namespaced folders, so nothing in + an existing project is overwritten: + + | Resource | Location | + |---|---| + | Views | `Exchange/AlarmAnalysis/…` | + | Style classes | `exchange/alarm-analysis/…` | + | Project library | `exchange/alarmAnalysis/{alarms, calc, fmt}` | + | Page | `/alarm-analysis` → `Exchange/AlarmAnalysis/Dashboard` | + +3. **Save** the project. +4. Open a Perspective session and browse to **`/alarm-analysis`**. + +That is the whole installation. The dashboard holds all of its own state on the +`Dashboard` view, so importing it does **not** add session custom properties, +client tags or global variables to your project. + +### If you imported into an existing project + +* The page URL `/alarm-analysis` is added to your project's page configuration. + If that URL is already taken, change it in **Project Browser → Perspective → + Page Configuration**; the view path it should point at is + `Exchange/AlarmAnalysis/Dashboard`. +* Nothing else in your project is touched — every resource is namespaced. + +### If you want it on its own page shell + +Embed `Exchange/AlarmAnalysis/Dashboard` in any view or dock. It takes no +parameters and sizes itself to its container (design size 1600×900). + +## Using it + +| Tab | What it shows | +|---|---| +| **Overview** | Headline KPIs vs the previous equal-length period, alarm-rate chart, priority mix, top sources, and plain-language insights. | +| **Analysis** | Day/hour heatmap, flood episodes, MTTA/MTTR trend, and priority distribution against the ISA 80/15/5 target. | +| **Bad Actors** | Pareto of the worst offenders plus chattering, standing and fleeting alarm tables. Click a row for the per-source drill-down. | +| **Journal** | Paged, filtered journal browser with CSV export and a per-event detail popup. | + +* The **range selector** and **Refresh** in the header set the analysis window. +* The **filter bar** (Priority / Area / State / Search) applies to every tab except + Overview, which deliberately stays a plant-wide summary. +* **Shift Report** renders a plain-text handover summary for the previous shift, + ready to copy into a log. +* Clicking a source anywhere drills through to that source in **Bad Actors**; + clicking a Bad Actors row opens the per-source history popup. + +## Tuning + +All thresholds live in one place — `exchange.alarmAnalysis.calc.DEFAULTS`: + +| Key | Default | Meaning | +|---|---|---| +| `target_per_hr` | `6.0` | ISA target alarm rate per operator per hour | +| `flood_per_10min` | `10` | Activations per 10 min that start a flood episode | +| `chatter_per_hr` | `10.0` | Re-triggers per hour that mark a source as chattering | +| `chatter_gap_s` | `120` | Median gap below which re-triggers count as chatter | +| `fleeting_s` | `10` | Active duration below which an alarm counts as fleeting | +| `standing_hours` | `24` | Age above which an active alarm counts as standing | +| `priority_target` | `80/15/5` | ISA low/medium/high priority mix | +| `health_weights` | see source | Weighting of the five health sub-scores | +| `max_events` | `50000` | Hard cap on journal rows read per query | + +Edit the dict in the Designer's script library, or pass an override dict as the +`opts` argument of any `calc` function. + +## Script library + +| Module | Responsibility | +|---|---| +| `exchange.alarmAnalysis.alarms` | Talks to `system.alarm`; the only module that does I/O. Entry points used by the views: `getDashboardBundle`, `getJournalPage`, `journalCsv`, `getSourceDetail`, `getEventData`, `getShiftReportText`. | +| `exchange.alarmAnalysis.calc` | Pure analytics — correlation, binning, floods, chatter, MTTA/MTTR, Pareto, health score. No Ignition API calls, so it is unit-testable. | +| `exchange.alarmAnalysis.fmt` | Display formatting and the natural-language insight/shift-report text. | + +Every public function carries a docstring, so the Designer's autocomplete +documents the API. + +## Notes and limits + +* The journal is queried for **twice** the selected window (current plus the + equal-length prior period) so every KPI can show a delta. A very wide range over + a busy plant is bounded by `max_events`; when that cap is hit the header shows a + *truncated* chip. +* Alarm instances are correlated by journal event id when the journal supplies + one, and by a per-source active/clear state machine otherwise. The mode in use + is reported in `bundle.meta.correlation_mode`. +* "Active now", unacked and shelved counts come from live alarm status, so they + reflect the present moment rather than the selected window. +* Scripts are Jython 2.7 compatible (no f-strings, no `typing`, no `statistics`). + +## License + +Provided as-is under the terms of the Ignition Exchange. diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/page-config/config.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/page-config/config.json new file mode 100644 index 0000000..0af8682 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/page-config/config.json @@ -0,0 +1,9 @@ +{ + "pages": { + "/alarm-analysis": { + "title": "Alarm Analysis", + "viewPath": "Exchange/AlarmAnalysis/Dashboard" + } + }, + "sharedDocks": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/page-config/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/page-config/resource.json new file mode 100644 index 0000000..b219fa3 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/page-config/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "config.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/session-props/props.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/session-props/props.json new file mode 100644 index 0000000..988a3c1 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/session-props/props.json @@ -0,0 +1,74 @@ +{ + "custom": {}, + "propConfig": { + "props.auth": { + "access": "PRIVATE", + "persistent": false + }, + "props.device.accelerometer": { + "access": "SYSTEM", + "persistent": false + }, + "props.device.identifier": { + "access": "SYSTEM", + "persistent": false + }, + "props.device.timezone": { + "access": "SYSTEM", + "persistent": false + }, + "props.device.type": { + "access": "SYSTEM", + "persistent": false + }, + "props.device.userAgent": { + "access": "SYSTEM", + "persistent": false + }, + "props.gateway": { + "access": "SYSTEM", + "persistent": false + }, + "props.geolocation.data": { + "access": "SYSTEM", + "persistent": false + }, + "props.geolocation.permissionGranted": { + "access": "SYSTEM", + "persistent": false + }, + "props.host": { + "access": "SYSTEM", + "persistent": false + }, + "props.id": { + "access": "SYSTEM", + "persistent": false + }, + "props.lastActivity": { + "access": "SYSTEM", + "persistent": false + }, + "props.offline.capable": { + "access": "SYSTEM", + "persistent": false + }, + "props.offline.enabled": { + "access": "SYSTEM", + "persistent": false + }, + "props.offline.lastSynced": { + "access": "SYSTEM", + "persistent": false + } + }, + "props": { + "device": {}, + "geolocation": {}, + "locale": "en-US", + "offline": {}, + "securityLevels": [], + "theme": "light-cool", + "timeZoneId": "America/Chicago" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/session-props/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/session-props/resource.json new file mode 100644 index 0000000..8b643ad --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/session-props/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "props.json" + ], + "attributes": {} +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card-title/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card-title/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card-title/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card-title/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card-title/style.json new file mode 100644 index 0000000..3692810 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card-title/style.json @@ -0,0 +1,12 @@ +{ + "base": { + "style": { + "color": "var(--neutral-70)", + "fontSize": "12px", + "fontWeight": "bold", + "letterSpacing": "0.08em", + "marginBottom": "8px", + "textTransform": "uppercase" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card/style.json new file mode 100644 index 0000000..a854646 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/card/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "var(--neutral-10)", + "borderRadius": "8px", + "boxShadow": "0 1px 3px rgba(0,0,0,0.12)", + "padding": "12px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/bad/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/bad/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/bad/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/bad/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/bad/style.json new file mode 100644 index 0000000..e0f5205 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/bad/style.json @@ -0,0 +1,13 @@ +{ + "base": { + "style": { + "backgroundColor": "#D6455026", + "borderRadius": "10px", + "color": "#D64550", + "fontSize": "11px", + "fontWeight": "bold", + "paddingLeft": "6px", + "paddingRight": "6px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/flat/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/flat/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/flat/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/flat/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/flat/style.json new file mode 100644 index 0000000..502ed05 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/flat/style.json @@ -0,0 +1,13 @@ +{ + "base": { + "style": { + "backgroundColor": "var(--neutral-30)", + "borderRadius": "10px", + "color": "var(--neutral-70)", + "fontSize": "11px", + "fontWeight": "bold", + "paddingLeft": "6px", + "paddingRight": "6px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/good/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/good/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/good/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/good/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/good/style.json new file mode 100644 index 0000000..43d83d2 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/chip/good/style.json @@ -0,0 +1,13 @@ +{ + "base": { + "style": { + "backgroundColor": "#2E9E5B26", + "borderRadius": "10px", + "color": "#2E9E5B", + "fontSize": "11px", + "fontWeight": "bold", + "paddingLeft": "6px", + "paddingRight": "6px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/empty-state/empty/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/empty-state/empty/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/empty-state/empty/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/empty-state/empty/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/empty-state/empty/style.json new file mode 100644 index 0000000..546e460 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/empty-state/empty/style.json @@ -0,0 +1,9 @@ +{ + "base": { + "style": { + "color": "var(--neutral-50)", + "fontStyle": "italic", + "textAlign": "center" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/a/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/a/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/a/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/a/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/a/style.json new file mode 100644 index 0000000..8687a3a --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/a/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#2E9E5B", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/b/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/b/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/b/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/b/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/b/style.json new file mode 100644 index 0000000..00fc826 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/b/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#7CB342", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/c/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/c/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/c/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/c/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/c/style.json new file mode 100644 index 0000000..90da82f --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/c/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#E5C453", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/d/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/d/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/d/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/d/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/d/style.json new file mode 100644 index 0000000..2e5d41a --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/d/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#E8883A", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/f/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/f/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/f/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/f/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/f/style.json new file mode 100644 index 0000000..83a3fd1 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/f/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#D64550", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/na/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/na/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/na/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/na/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/na/style.json new file mode 100644 index 0000000..7407c82 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/grade/na/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#8A94A6", + "borderRadius": "8px", + "color": "#FFFFFF", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/page-bg/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/page-bg/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/page-bg/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/page-bg/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/page-bg/style.json new file mode 100644 index 0000000..5b96b14 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/page-bg/style.json @@ -0,0 +1,8 @@ +{ + "base": { + "style": { + "backgroundColor": "var(--neutral-20)", + "padding": "12px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/critical/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/critical/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/critical/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/critical/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/critical/style.json new file mode 100644 index 0000000..7fdf1d9 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/critical/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#D6455026", + "borderRadius": "6px", + "color": "#D64550", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/diagnostic/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/diagnostic/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/diagnostic/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/diagnostic/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/diagnostic/style.json new file mode 100644 index 0000000..3d2bc2a --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/diagnostic/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#8A94A626", + "borderRadius": "6px", + "color": "#8A94A6", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/high/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/high/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/high/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/high/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/high/style.json new file mode 100644 index 0000000..a4b447e --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/high/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#E8883A26", + "borderRadius": "6px", + "color": "#E8883A", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/low/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/low/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/low/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/low/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/low/style.json new file mode 100644 index 0000000..bd7ae88 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/low/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#5B9BD526", + "borderRadius": "6px", + "color": "#5B9BD5", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/medium/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/medium/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/medium/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/medium/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/medium/style.json new file mode 100644 index 0000000..32eb40d --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority-soft/medium/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "#E5C45326", + "borderRadius": "6px", + "color": "#E5C453", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/critical/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/critical/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/critical/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/critical/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/critical/style.json new file mode 100644 index 0000000..c2b98f9 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/critical/style.json @@ -0,0 +1,15 @@ +{ + "base": { + "style": { + "backgroundColor": "#D64550", + "borderRadius": "4px", + "color": "#FFFFFF", + "fontSize": "12px", + "fontWeight": "bold", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/diagnostic/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/diagnostic/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/diagnostic/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/diagnostic/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/diagnostic/style.json new file mode 100644 index 0000000..0cc5b4b --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/diagnostic/style.json @@ -0,0 +1,15 @@ +{ + "base": { + "style": { + "backgroundColor": "#8A94A6", + "borderRadius": "4px", + "color": "#FFFFFF", + "fontSize": "12px", + "fontWeight": "bold", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/high/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/high/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/high/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/high/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/high/style.json new file mode 100644 index 0000000..0be2860 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/high/style.json @@ -0,0 +1,15 @@ +{ + "base": { + "style": { + "backgroundColor": "#E8883A", + "borderRadius": "4px", + "color": "#FFFFFF", + "fontSize": "12px", + "fontWeight": "bold", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/low/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/low/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/low/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/low/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/low/style.json new file mode 100644 index 0000000..b1bd3c2 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/low/style.json @@ -0,0 +1,15 @@ +{ + "base": { + "style": { + "backgroundColor": "#5B9BD5", + "borderRadius": "4px", + "color": "#FFFFFF", + "fontSize": "12px", + "fontWeight": "bold", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/medium/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/medium/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/medium/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/medium/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/medium/style.json new file mode 100644 index 0000000..e4881aa --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/priority/medium/style.json @@ -0,0 +1,15 @@ +{ + "base": { + "style": { + "backgroundColor": "#E5C453", + "borderRadius": "4px", + "color": "#FFFFFF", + "fontSize": "12px", + "fontWeight": "bold", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/acked/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/acked/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/acked/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/acked/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/acked/style.json new file mode 100644 index 0000000..a353a46 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/acked/style.json @@ -0,0 +1,8 @@ +{ + "base": { + "style": { + "backgroundColor": "#3B7DD818", + "color": "#3B7DD8" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/active/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/active/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/active/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/active/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/active/style.json new file mode 100644 index 0000000..6945a81 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/active/style.json @@ -0,0 +1,9 @@ +{ + "base": { + "style": { + "backgroundColor": "#D6455018", + "color": "#D64550", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/cleared/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/cleared/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/cleared/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/cleared/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/cleared/style.json new file mode 100644 index 0000000..e3001f8 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/state/cleared/style.json @@ -0,0 +1,8 @@ +{ + "base": { + "style": { + "backgroundColor": "#2E9E5B18", + "color": "#2E9E5B" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/active/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/active/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/active/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/active/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/active/style.json new file mode 100644 index 0000000..161235e --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/active/style.json @@ -0,0 +1,16 @@ +{ + "base": { + "style": { + "borderBottomColor": "#3B7DD8", + "borderBottomStyle": "solid", + "borderBottomWidth": "3px", + "color": "var(--neutral-90)", + "cursor": "pointer", + "fontWeight": "bold", + "paddingBottom": "6px", + "paddingLeft": "16px", + "paddingRight": "16px", + "paddingTop": "8px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/inactive/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/inactive/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/inactive/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/inactive/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/inactive/style.json new file mode 100644 index 0000000..d5c7c93 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/tab/inactive/style.json @@ -0,0 +1,23 @@ +{ + "base": { + "style": { + "borderBottomColor": "rgba(0,0,0,0)", + "borderBottomStyle": "solid", + "borderBottomWidth": "3px", + "color": "var(--neutral-60)", + "cursor": "pointer", + "paddingBottom": "6px", + "paddingLeft": "16px", + "paddingRight": "16px", + "paddingTop": "8px" + } + }, + "variants": [ + { + "pseudo": "hover", + "style": { + "color": "var(--neutral-90)" + } + } + ] +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/big/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/big/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/big/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/big/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/big/style.json new file mode 100644 index 0000000..f5e71ef --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/big/style.json @@ -0,0 +1,9 @@ +{ + "base": { + "style": { + "color": "var(--neutral-90)", + "fontSize": "28px", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/kpi/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/kpi/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/kpi/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/kpi/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/kpi/style.json new file mode 100644 index 0000000..57c2b23 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/kpi/style.json @@ -0,0 +1,9 @@ +{ + "base": { + "style": { + "color": "var(--neutral-90)", + "fontSize": "22px", + "fontWeight": "bold" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/muted/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/muted/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/muted/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/muted/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/muted/style.json new file mode 100644 index 0000000..ddf5eab --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/text/muted/style.json @@ -0,0 +1,8 @@ +{ + "base": { + "style": { + "color": "var(--neutral-60)", + "fontSize": "12px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/toolbar/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/toolbar/resource.json new file mode 100644 index 0000000..61c08bb --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/toolbar/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "style.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/toolbar/style.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/toolbar/style.json new file mode 100644 index 0000000..125b252 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/style-classes/exchange/alarm-analysis/toolbar/style.json @@ -0,0 +1,10 @@ +{ + "base": { + "style": { + "backgroundColor": "var(--neutral-10)", + "borderRadius": "8px", + "boxShadow": "0 1px 3px rgba(0,0,0,0.12)", + "padding": "8px" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/EmptyState/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/EmptyState/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/EmptyState/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/EmptyState/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/EmptyState/view.json new file mode 100644 index 0000000..9cd3c4b --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/EmptyState/view.json @@ -0,0 +1,93 @@ +{ + "custom": {}, + "params": { + "icon": null, + "message": null + }, + "propConfig": { + "params.icon": { + "paramDirection": "input", + "persistent": false + }, + "params.message": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "Icon" + }, + "position": { + "basis": "48px", + "shrink": 0 + }, + "propConfig": { + "props.path": { + "binding": { + "config": { + "path": "view.params.icon" + }, + "transforms": [ + { + "code": "\treturn 'material/' + (value or 'inbox')", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "color": "var(--neutral-50)", + "path": "material/inbox", + "style": { + "fontSize": "40px" + } + }, + "type": "ia.display.icon" + }, + { + "meta": { + "name": "Msg" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.message" + }, + "transforms": [ + { + "code": "\treturn value or 'No alarm events in the selected range'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/empty-state/empty" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "alignItems": "center", + "justifyContent": "center" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HealthGauge/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HealthGauge/resource.json new file mode 100644 index 0000000..1807fb7 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HealthGauge/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "44b87b06b842940d391b9bafd34557de0ac8a5005f6ee91844ac9dd2f18c0f04", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:14:21Z" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HealthGauge/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HealthGauge/view.json new file mode 100644 index 0000000..0d314e4 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HealthGauge/view.json @@ -0,0 +1,136 @@ +{ + "custom": {}, + "params": { + "health": null + }, + "propConfig": { + "params.health": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "Grade" + }, + "propConfig": { + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tg = value['grade']\n\texcept:\n\t\tg = None\n\treturn 'exchange/alarm-analysis/grade/' + (g.lower() if g in ['A', 'B', 'C', 'D', 'F'] else 'na')", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['grade'] or 'N/A'\n\texcept:\n\t\treturn 'N/A'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "30px", + "paddingBottom": "2px", + "paddingLeft": "14px", + "paddingRight": "14px", + "paddingTop": "2px", + "textAlign": "center" + } + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "meta": { + "name": "Title" + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold" + }, + "text": "Alarm Health" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Score" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ts = value['score']\n\t\treturn 'Score %d - click for detail' % round(s) if s is not None else 'no data in range'\n\texcept:\n\t\treturn 'no data in range'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "ScoreCol" + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + } + ], + "events": { + "dom": { + "onClick": { + "config": { + "script": "\tsystem.perspective.openPopup('exchange.alarmAnalysis.healthScore', 'Exchange/AlarmAnalysis/Popups/HealthScore', params = {'health': self.view.params.health}, showCloseIcon = True, draggable = True)" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "root" + }, + "props": { + "style": { + "alignItems": "center", + "cursor": "pointer", + "gap": "10px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HeatmapCell/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HeatmapCell/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HeatmapCell/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HeatmapCell/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HeatmapCell/view.json new file mode 100644 index 0000000..9e79277 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/HeatmapCell/view.json @@ -0,0 +1,94 @@ +{ + "custom": {}, + "params": { + "count": null, + "max": null + }, + "propConfig": { + "params.count": { + "paramDirection": "input", + "persistent": false + }, + "params.max": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "Cell" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.style.backgroundColor": { + "binding": { + "config": { + "path": "view.params.count" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tm = self.view.params.max\n\t\tc = value or 0\n\t\tif not m or m <= 0 or c <= 0:\n\t\t\treturn 'rgba(59,125,216,0.06)'\n\t\ta = 0.15 + 0.85 * (float(c) / m)\n\t\treturn 'rgba(59,125,216,%.2f)' % min(a, 1.0)\n\texcept:\n\t\treturn 'rgba(59,125,216,0.06)'", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.color": { + "binding": { + "config": { + "path": "view.params.count" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tm = self.view.params.max\n\t\tif m and value and float(value) / m > 0.55:\n\t\t\treturn '#FFFFFF'\n\texcept:\n\t\tpass\n\treturn 'var(--neutral-90)'", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.count" + }, + "transforms": [ + { + "code": "\treturn '' if not value else value", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "borderRadius": "2px", + "fontSize": "10px", + "marginBottom": "1px", + "marginRight": "1px", + "textAlign": "center" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "row", + "style": { + "alignItems": "stretch" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/InsightRow/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/InsightRow/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/InsightRow/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/InsightRow/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/InsightRow/view.json new file mode 100644 index 0000000..5026120 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/InsightRow/view.json @@ -0,0 +1,121 @@ +{ + "custom": {}, + "params": { + "icon": null, + "severity": null, + "tab": null, + "text": null + }, + "propConfig": { + "params.icon": { + "paramDirection": "input", + "persistent": false + }, + "params.severity": { + "paramDirection": "input", + "persistent": false + }, + "params.tab": { + "paramDirection": "input", + "persistent": false + }, + "params.text": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "Icon" + }, + "position": { + "basis": "24px", + "shrink": 0 + }, + "propConfig": { + "props.color": { + "binding": { + "config": { + "path": "view.params.severity" + }, + "transforms": [ + { + "code": "\tcolors = {0: '#D64550', 1: '#E8883A', 2: '#3B7DD8', 3: 'var(--neutral-60)'}\n\treturn colors.get(value, 'var(--neutral-60)')", + "type": "script" + } + ], + "type": "property" + } + }, + "props.path": { + "binding": { + "config": { + "path": "view.params.icon" + }, + "transforms": [ + { + "code": "\treturn 'material/' + (value or 'insights')", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "path": "material/insights", + "style": { + "fontSize": "18px" + } + }, + "type": "ia.display.icon" + }, + { + "meta": { + "name": "Text" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.text" + }, + "transforms": [ + { + "code": "\treturn value or ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "row", + "style": { + "alignItems": "center", + "gap": "8px", + "paddingBottom": "4px", + "paddingTop": "4px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/KpiCard/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/KpiCard/resource.json new file mode 100644 index 0000000..f597cc7 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/KpiCard/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "75b1aa709e8638ac045f15b7a6d3543d023b8f3e886e2f9f1c263c0390c4fe9d", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:45:13Z" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/KpiCard/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/KpiCard/view.json new file mode 100644 index 0000000..74b80e7 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/KpiCard/view.json @@ -0,0 +1,204 @@ +{ + "custom": {}, + "params": { + "delta": null, + "label": null, + "unit": null, + "value": null + }, + "propConfig": { + "params.delta": { + "paramDirection": "input", + "persistent": false + }, + "params.label": { + "paramDirection": "input", + "persistent": false + }, + "params.unit": { + "paramDirection": "input", + "persistent": false + }, + "params.value": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "Lbl" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.label" + }, + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "lineHeight": "1.1" + } + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "meta": { + "name": "Value" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.value" + }, + "transforms": [ + { + "code": "\treturn value if value is not None else '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/kpi", + "lineHeight": "1.1" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Unit" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.unit" + }, + "transforms": [ + { + "code": "\treturn value or ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "lineHeight": "1.1" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "ValueRow" + }, + "position": { + "basis": "auto", + "grow": 1, + "shrink": 1 + }, + "props": { + "style": { + "alignItems": "baseline", + "gap": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "Chip" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.delta" + }, + "transforms": [ + { + "code": "\treturn value is not None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.delta" + }, + "transforms": [ + { + "code": "\tbase = 'exchange/alarm-analysis/chip/flat'\n\ttry:\n\t\tif value and value['good'] == True:\n\t\t\tbase = 'exchange/alarm-analysis/chip/good'\n\t\telif value and value['good'] == False:\n\t\t\tbase = 'exchange/alarm-analysis/chip/bad'\n\texcept:\n\t\tpass\n\treturn base", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.delta" + }, + "transforms": [ + { + "code": "\tif not value:\n\t\treturn ''\n\ttry:\n\t\tchip = exchange.alarmAnalysis.fmt.deltaChip(value)\n\t\treturn '%s %s' % (chip['arrow'], chip['text'])\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "alignSelf": "flex-start", + "padding": "0 6px" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "justifyContent": "space-between", + "overflow": "hidden", + "padding": "6px 12px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/PriorityCard/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/PriorityCard/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/PriorityCard/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/PriorityCard/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/PriorityCard/view.json new file mode 100644 index 0000000..bb89eb7 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/PriorityCard/view.json @@ -0,0 +1,137 @@ +{ + "custom": {}, + "params": { + "count": null, + "pct": null, + "priorityName": null + }, + "propConfig": { + "params.count": { + "paramDirection": "input", + "persistent": false + }, + "params.pct": { + "paramDirection": "input", + "persistent": false + }, + "params.priorityName": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "Name" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.priorityName" + }, + "transforms": [ + { + "code": "\treturn value or 'Unknown'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Count" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.count" + }, + "transforms": [ + { + "code": "\treturn exchange.alarmAnalysis.fmt.num(value)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/big" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Pct" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.pct" + }, + "transforms": [ + { + "code": "\treturn exchange.alarmAnalysis.fmt.pct(value) + ' of events' if value is not None else ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "propConfig": { + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.priorityName" + }, + "transforms": [ + { + "code": "\tnames = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tn = value if value in names else 'Diagnostic'\n\treturn 'exchange/alarm-analysis/card exchange/alarm-analysis/priority-soft/' + n.lower()", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "alignItems": "center", + "classes": "exchange/alarm-analysis/card", + "gap": "2px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/ScoreBar/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/ScoreBar/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/ScoreBar/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/ScoreBar/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/ScoreBar/view.json new file mode 100644 index 0000000..726f47a --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/ScoreBar/view.json @@ -0,0 +1,207 @@ +{ + "custom": {}, + "params": { + "detail": null, + "key": null, + "label": null, + "score": null, + "weight": null + }, + "propConfig": { + "params.detail": { + "paramDirection": "input", + "persistent": false + }, + "params.key": { + "paramDirection": "input", + "persistent": false + }, + "params.label": { + "paramDirection": "input", + "persistent": false + }, + "params.score": { + "paramDirection": "input", + "persistent": false + }, + "params.weight": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "Lbl" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.label" + }, + "transforms": [ + { + "code": "\treturn value or ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Score" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.score" + }, + "transforms": [ + { + "code": "\treturn '%d' % round(value) if value is not None else 'N/A'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "TopRow" + }, + "props": { + "direction": "row", + "style": { + "justifyContent": "space-between" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [], + "meta": { + "name": "Fill" + }, + "propConfig": { + "props.style.backgroundColor": { + "binding": { + "config": { + "path": "view.params.score" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ts = float(value)\n\texcept:\n\t\treturn 'var(--neutral-40)'\n\tif s >= 80:\n\t\treturn '#2E9E5B'\n\tif s >= 60:\n\t\treturn '#E5C453'\n\treturn '#D64550'", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.width": { + "binding": { + "config": { + "path": "view.params.score" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn '%d%%' % max(0, min(100, round(value)))\n\texcept:\n\t\treturn '0%'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "row", + "style": { + "borderRadius": "4px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "Track" + }, + "position": { + "basis": "8px", + "shrink": 0 + }, + "props": { + "direction": "row", + "style": { + "backgroundColor": "var(--neutral-30)", + "borderRadius": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "Detail" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.detail" + }, + "transforms": [ + { + "code": "\treturn value or ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "gap": "2px", + "paddingBottom": "8px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/SourceRow/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/SourceRow/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/SourceRow/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/SourceRow/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/SourceRow/view.json new file mode 100644 index 0000000..7c38ac1 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/SourceRow/view.json @@ -0,0 +1,192 @@ +{ + "custom": {}, + "params": { + "count": null, + "label": null, + "pct": null, + "rank": null, + "source": null + }, + "propConfig": { + "params.count": { + "paramDirection": "input", + "persistent": false + }, + "params.label": { + "paramDirection": "input", + "persistent": false + }, + "params.pct": { + "paramDirection": "input", + "persistent": false + }, + "params.rank": { + "paramDirection": "input", + "persistent": false + }, + "params.source": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "meta": { + "name": "Rank" + }, + "position": { + "basis": "24px", + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.rank" + }, + "transforms": [ + { + "code": "\treturn '#%s' % value if value is not None else ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Lbl" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.label" + }, + "transforms": [ + { + "code": "\treturn value or ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Count" + }, + "position": { + "basis": "56px", + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.count" + }, + "transforms": [ + { + "code": "\treturn exchange.alarmAnalysis.fmt.num(value)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "13px", + "fontWeight": "bold", + "textAlign": "right" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Pct" + }, + "position": { + "basis": "48px", + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.pct" + }, + "transforms": [ + { + "code": "\treturn exchange.alarmAnalysis.fmt.pct(value) if value is not None else ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "textAlign": "right" + } + }, + "type": "ia.display.label" + } + ], + "events": { + "dom": { + "onClick": { + "config": { + "script": "\tsystem.perspective.sendMessage('exchange.alarmAnalysis.focusSource', payload = {'source': self.view.params.source}, scope = 'page')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "root" + }, + "props": { + "direction": "row", + "style": { + "alignItems": "center", + "borderBottomColor": "var(--neutral-30)", + "borderBottomStyle": "solid", + "borderBottomWidth": "1px", + "cursor": "pointer", + "gap": "8px", + "paddingBottom": "4px", + "paddingTop": "4px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/TabButton/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/TabButton/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/TabButton/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/TabButton/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/TabButton/view.json new file mode 100644 index 0000000..50ff7ae --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Components/TabButton/view.json @@ -0,0 +1,74 @@ +{ + "custom": {}, + "params": { + "index": null, + "selectedTab": null, + "title": null + }, + "propConfig": { + "params.index": { + "paramDirection": "input", + "persistent": false + }, + "params.selectedTab": { + "paramDirection": "input", + "persistent": false + }, + "params.title": { + "paramDirection": "input", + "persistent": false + } + }, + "props": {}, + "root": { + "children": [ + { + "events": { + "dom": { + "onClick": { + "config": { + "script": "\tsystem.perspective.sendMessage('exchange.alarmAnalysis.setTab', payload = {'index': self.view.params.index}, scope = 'page')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "Btn" + }, + "propConfig": { + "props.style.classes": { + "binding": { + "config": { + "expression": "if({view.params.index} = {view.params.selectedTab}, 'exchange/alarm-analysis/tab/active', 'exchange/alarm-analysis/tab/inactive')" + }, + "type": "expr" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.title" + }, + "type": "property" + } + } + }, + "props": {}, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "row", + "style": { + "alignItems": "center", + "justifyContent": "center" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Dashboard/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Dashboard/resource.json new file mode 100644 index 0000000..d0fbe8c --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Dashboard/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "04bdfb9c4eecc4f8cf35a85994e6790ef8d62a1d1aa00553e4dc4193e171a110", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:31:35Z" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Dashboard/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Dashboard/view.json new file mode 100644 index 0000000..c6e956d --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Dashboard/view.json @@ -0,0 +1,487 @@ +{ + "custom": { + "bundle": null, + "query": { + "areas": [], + "endMs": 0, + "priorities": [], + "rangePreset": "8h", + "refreshToken": 0, + "search": "", + "startMs": 0, + "states": [] + }, + "ui": { + "badActorFocus": "", + "selectedTab": 0 + } + }, + "events": { + "system": { + "onStartup": { + "config": { + "script": "\tq = self.view.custom.query\n\tif not q.startMs or q.startMs == 0:\n\t\tnow = system.date.toMillis(system.date.now())\n\t\tq.endMs = now\n\t\tq.startMs = now - 8 * 3600 * 1000\n\t\tq.rangePreset = '8h'" + }, + "scope": "G", + "type": "script" + } + } + }, + "params": {}, + "propConfig": { + "custom.bundle": { + "binding": { + "config": { + "expression": "{view.custom.query.refreshToken} + '|' + {view.custom.query.startMs} + '|' + {view.custom.query.endMs}" + }, + "transforms": [ + { + "code": "\tq = self.view.custom.query\n\tif not q.startMs or not q.endMs:\n\t\treturn None\n\treturn exchange.alarmAnalysis.alarms.getDashboardBundle(q.startMs, q.endMs, {'priorities': list(q.priorities), 'areas': list(q.areas), 'states': list(q.states), 'search': q.search})", + "type": "script" + } + ], + "type": "expr" + } + }, + "custom.query": { + "persistent": false + }, + "custom.ui": { + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 900, + "width": 1600 + } + }, + "root": { + "children": [ + { + "meta": { + "name": "Header" + }, + "position": { + "basis": "150px", + "shrink": 0 + }, + "propConfig": { + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + }, + "props.params.query": { + "binding": { + "config": { + "path": "view.custom.query" + }, + "type": "property" + } + } + }, + "props": { + "path": "Exchange/AlarmAnalysis/Shell/Header" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "FilterBar" + }, + "position": { + "basis": "auto", + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{view.custom.ui.selectedTab} != 0" + }, + "type": "expr" + } + }, + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + }, + "props.params.query": { + "binding": { + "config": { + "path": "view.custom.query" + }, + "type": "property" + } + } + }, + "props": { + "path": "Exchange/AlarmAnalysis/Shell/FilterBar", + "style": { + "minHeight": "56px" + } + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "meta": { + "name": "TabBtn0" + }, + "position": { + "basis": "140px", + "shrink": 0 + }, + "propConfig": { + "props.params.selectedTab": { + "binding": { + "config": { + "path": "view.custom.ui.selectedTab" + }, + "type": "property" + } + } + }, + "props": { + "params": { + "index": 0, + "title": "Overview" + }, + "path": "Exchange/AlarmAnalysis/Components/TabButton" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "TabBtn1" + }, + "position": { + "basis": "140px", + "shrink": 0 + }, + "propConfig": { + "props.params.selectedTab": { + "binding": { + "config": { + "path": "view.custom.ui.selectedTab" + }, + "type": "property" + } + } + }, + "props": { + "params": { + "index": 1, + "title": "Analysis" + }, + "path": "Exchange/AlarmAnalysis/Components/TabButton" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "TabBtn2" + }, + "position": { + "basis": "140px", + "shrink": 0 + }, + "propConfig": { + "props.params.selectedTab": { + "binding": { + "config": { + "path": "view.custom.ui.selectedTab" + }, + "type": "property" + } + } + }, + "props": { + "params": { + "index": 2, + "title": "Bad Actors" + }, + "path": "Exchange/AlarmAnalysis/Components/TabButton" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "TabBtn3" + }, + "position": { + "basis": "140px", + "shrink": 0 + }, + "propConfig": { + "props.params.selectedTab": { + "binding": { + "config": { + "path": "view.custom.ui.selectedTab" + }, + "type": "property" + } + } + }, + "props": { + "params": { + "index": 3, + "title": "Journal" + }, + "path": "Exchange/AlarmAnalysis/Components/TabButton" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "TabBar" + }, + "position": { + "basis": "42px", + "shrink": 0 + }, + "props": { + "style": { + "borderBottomColor": "var(--neutral-40)", + "borderBottomStyle": "solid", + "borderBottomWidth": "1px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "TabOverview" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{view.custom.ui.selectedTab} = 0" + }, + "type": "expr" + } + }, + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + } + }, + "props": { + "path": "Exchange/AlarmAnalysis/Tabs/Overview" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "TabAnalysis" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{view.custom.ui.selectedTab} = 1" + }, + "type": "expr" + } + }, + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + } + }, + "props": { + "path": "Exchange/AlarmAnalysis/Tabs/Analysis" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "TabBadActors" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{view.custom.ui.selectedTab} = 2" + }, + "type": "expr" + } + }, + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + }, + "props.params.focus": { + "binding": { + "config": { + "path": "view.custom.ui.badActorFocus" + }, + "type": "property" + } + }, + "props.params.query": { + "binding": { + "config": { + "path": "view.custom.query" + }, + "type": "property" + } + } + }, + "props": { + "path": "Exchange/AlarmAnalysis/Tabs/BadActors" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "TabJournal" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{view.custom.ui.selectedTab} = 3" + }, + "type": "expr" + } + }, + "props.params.bundle": { + "binding": { + "config": { + "path": "view.custom.bundle" + }, + "type": "property" + } + }, + "props.params.query": { + "binding": { + "config": { + "path": "view.custom.query" + }, + "type": "property" + } + } + }, + "props": { + "path": "Exchange/AlarmAnalysis/Tabs/Journal" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "TabContent" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/page-bg", + "gap": "10px" + } + }, + "scripts": { + "customMethods": [], + "extensionFunctions": null, + "messageHandlers": [ + { + "messageType": "exchange.alarmAnalysis.setTab", + "pageScope": true, + "script": "\ttry:\n\t\tself.view.custom.ui.selectedTab = int(payload['index'])\n\texcept:\n\t\tpass", + "sessionScope": false, + "viewScope": false + }, + { + "messageType": "exchange.alarmAnalysis.focusSource", + "pageScope": true, + "script": "\ttry:\n\t\tself.view.custom.ui.badActorFocus = payload['source'] or ''\n\t\tself.view.custom.ui.selectedTab = 2\n\texcept:\n\t\tpass", + "sessionScope": false, + "viewScope": false + }, + { + "messageType": "exchange.alarmAnalysis.clearFocus", + "pageScope": true, + "script": "\tself.view.custom.ui.badActorFocus = ''", + "sessionScope": false, + "viewScope": false + }, + { + "messageType": "exchange.alarmAnalysis.applyFilters", + "pageScope": true, + "script": "\tq = self.view.custom.query\n\ttry:\n\t\tq.priorities = list(payload['priorities'] or [])\n\t\tq.areas = list(payload['areas'] or [])\n\t\tq.states = list(payload['states'] or [])\n\t\tq.search = str(payload['search'] or '')\n\texcept:\n\t\tpass\n\tq.refreshToken = q.refreshToken + 1", + "sessionScope": false, + "viewScope": false + }, + { + "messageType": "exchange.alarmAnalysis.clearFilters", + "pageScope": true, + "script": "\tq = self.view.custom.query\n\tq.priorities = []\n\tq.areas = []\n\tq.states = []\n\tq.search = ''\n\tq.refreshToken = q.refreshToken + 1", + "sessionScope": false, + "viewScope": false + }, + { + "messageType": "exchange.alarmAnalysis.setRange", + "pageScope": true, + "script": "\tspans = {'4h': 4, '8h': 8, '24h': 24, '7d': 168}\n\ttry:\n\t\tpreset = payload['preset']\n\texcept:\n\t\treturn\n\tif preset not in spans:\n\t\treturn\n\tq = self.view.custom.query\n\tnow = system.date.toMillis(system.date.now())\n\tq.endMs = now\n\tq.startMs = now - spans[preset] * 3600 * 1000\n\tq.rangePreset = preset\n\tq.refreshToken = q.refreshToken + 1", + "sessionScope": false, + "viewScope": false + }, + { + "messageType": "exchange.alarmAnalysis.refresh", + "pageScope": true, + "script": "\tq = self.view.custom.query\n\tspans = {'4h': 4, '8h': 8, '24h': 24, '7d': 168}\n\tnow = system.date.toMillis(system.date.now())\n\thours = spans.get(q.rangePreset)\n\tif hours is not None:\n\t\tspan = hours * 3600 * 1000\n\telse:\n\t\tspan = q.endMs - q.startMs\n\tif span > 0:\n\t\tq.endMs = now\n\t\tq.startMs = now - span\n\tq.refreshToken = q.refreshToken + 1", + "sessionScope": false, + "viewScope": false + } + ] + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/AlarmDetail/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/AlarmDetail/resource.json new file mode 100644 index 0000000..fc7d184 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/AlarmDetail/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "1d6feda3a22e57c52a032bea5ed828b0b7836e249e46a68faa58af39207b9119", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:41:00Z" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/AlarmDetail/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/AlarmDetail/view.json new file mode 100644 index 0000000..4f32849 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/AlarmDetail/view.json @@ -0,0 +1,805 @@ +{ + "custom": { + "detail": null + }, + "params": { + "endMs": null, + "label": null, + "source": null, + "startMs": null + }, + "propConfig": { + "custom.detail": { + "binding": { + "config": { + "expression": "{view.params.source} + '|' + {view.params.startMs} + '|' + {view.params.endMs}" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tsrc = self.view.params.source\n\texcept:\n\t\tsrc = None\n\tif not src:\n\t\treturn None\n\ttry:\n\t\treturn exchange.alarmAnalysis.alarms.getSourceDetail(src, self.view.params.startMs, self.view.params.endMs)\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "expr" + } + }, + "params.endMs": { + "paramDirection": "input", + "persistent": false + }, + "params.label": { + "paramDirection": "input", + "persistent": false + }, + "params.source": { + "paramDirection": "input", + "persistent": false + }, + "params.startMs": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 660, + "width": 720 + } + }, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblTitle" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.label" + }, + "transforms": [ + { + "code": "\tif value:\n\t\treturn value\n\ttry:\n\t\treturn self.view.params.source or 'Alarm Detail'\n\texcept:\n\t\treturn 'Alarm Detail'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/big" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "LblArea" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['area'] or ''\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "HeaderCol" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "KpiCount" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.value": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.num(value['stats']['count'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Activations" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiTta" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.value": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.dur(value['stats']['avg_tta_ms'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Avg Time to Ack" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiActive" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.value": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.dur(value['stats']['avg_active_ms'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Avg Active Time" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiFleeting" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.value": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.num(value['stats']['fleeting_count'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Fleeting" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "StatsRow" + }, + "position": { + "basis": "88px", + "shrink": 0 + }, + "props": { + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblDailyTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Daily activity" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "RepDaily" + }, + "position": { + "basis": "72px", + "shrink": 0 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tdaily = list(value['daily'] or [])\n\texcept:\n\t\tdaily = []\n\tcounts = []\n\tfor d in daily:\n\t\ttry:\n\t\t\tcounts.append(int(d['count']))\n\t\texcept:\n\t\t\tcounts.append(0)\n\tif not counts:\n\t\treturn []\n\tm = max(counts)\n\treturn [{'count': c, 'max': m} for c in counts]", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "8px" + }, + "path": "Exchange/AlarmAnalysis/Components/HeatmapCell", + "style": { + "gap": "2px" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + }, + { + "meta": { + "name": "LblDailyRange" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tdaily = list(value['daily'] or [])\n\texcept:\n\t\tdaily = []\n\tif not daily:\n\t\treturn 'No activity in range'\n\ttry:\n\t\treturn 'Activations per day, %s - %s' % (exchange.alarmAnalysis.fmt.dayClock(daily[0]['t0']), exchange.alarmAnalysis.fmt.dayClock(daily[-1]['t0']))\n\texcept:\n\t\treturn 'Activations per day'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "CardDaily" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblAckTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Top ack users" + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "meta": { + "name": "Ack0" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['stats']['top_ack_users']) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tu = value['stats']['top_ack_users'][0]\n\t\treturn '%s (%d)' % (u['user'], u['count'])\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/chip/flat", + "paddingBottom": "2px", + "paddingTop": "2px" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Ack1" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['stats']['top_ack_users']) > 1\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tu = value['stats']['top_ack_users'][1]\n\t\treturn '%s (%d)' % (u['user'], u['count'])\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/chip/flat", + "paddingBottom": "2px", + "paddingTop": "2px" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Ack2" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['stats']['top_ack_users']) > 2\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tu = value['stats']['top_ack_users'][2]\n\t\treturn '%s (%d)' % (u['user'], u['count'])\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/chip/flat", + "paddingBottom": "2px", + "paddingTop": "2px" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "LblNoAcks" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['stats']['top_ack_users']) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "No acknowledgements in range" + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "AckRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "6px" + }, + "wrap": "wrap" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "CardAcks" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblEvTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Recent events" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "TblEvents" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.data": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\tout = []\n\ttry:\n\t\trows = list(value['events'] or [])\n\texcept:\n\t\trows = []\n\tfor r in rows[:25]:\n\t\ttry:\n\t\t\tout.append({'time_label': r['time_label'], 'state_label': r['state_label'], 'ack_user': r['ack_user'] or ''})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "time_label", + "header": { + "title": "Time" + }, + "render": "string", + "sortable": false, + "strictWidth": true, + "width": 160 + }, + { + "field": "state_label", + "header": { + "title": "State" + }, + "render": "string", + "sortable": false, + "strictWidth": true, + "width": 110 + }, + { + "field": "ack_user", + "header": { + "title": "Ack by" + }, + "render": "string", + "sortable": false + } + ], + "emptyMessage": { + "noData": { + "text": "No events in range" + } + }, + "pager": { + "bottom": false + } + }, + "type": "ia.display.table" + } + ], + "meta": { + "name": "CardEvents" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "BodyCol" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\tif value is None:\n\t\treturn False\n\ttry:\n\t\treturn not value['error']\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "EmptyWrap" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.detail" + }, + "transforms": [ + { + "code": "\tif value is None:\n\t\treturn True\n\ttry:\n\t\treturn bool(value['error'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "notifications_off", + "message": "No journal data for this alarm in the selected range" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.closePopup('exchange.alarmAnalysis.alarmDetail')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "BtnClose" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "text": "Close" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "FooterRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "flex-end" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/page-bg", + "gap": "10px", + "padding": "16px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/EventDetail/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/EventDetail/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/EventDetail/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/EventDetail/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/EventDetail/view.json new file mode 100644 index 0000000..830d92c --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/EventDetail/view.json @@ -0,0 +1,665 @@ +{ + "custom": { + "props": [] + }, + "params": { + "event": null + }, + "propConfig": { + "custom.props": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\tev = value\n\tif not ev:\n\t\treturn []\n\ttry:\n\t\tres = exchange.alarmAnalysis.alarms.getEventData(ev['id'], ev['time_ms'], ev['source'])\n\t\treturn res['props'] or []\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + }, + "params.event": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 580, + "width": 580 + } + }, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblTitle" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['label'] or value['source'] or 'Event Detail'\n\texcept:\n\t\treturn 'Event Detail'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/big" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ChipState" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\treturn value is not None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ts = value['state']\n\texcept:\n\t\ts = None\n\tif s == 'ack':\n\t\treturn 'exchange/alarm-analysis/state/acked'\n\tif s == 'clear':\n\t\treturn 'exchange/alarm-analysis/state/cleared'\n\treturn 'exchange/alarm-analysis/state/active'", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['state_label'] or ''\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "borderRadius": "10px", + "fontSize": "11px", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ChipPriority" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\treturn value is not None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tp = value['priority_name']\n\texcept:\n\t\tp = None\n\tif p not in ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']:\n\t\tp = 'Diagnostic'\n\treturn 'exchange/alarm-analysis/priority/' + p.lower()", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['priority_name'] or ''\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "borderRadius": "10px", + "fontSize": "11px", + "paddingBottom": "2px", + "paddingLeft": "8px", + "paddingRight": "8px", + "paddingTop": "2px" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "HeaderRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "meta": { + "name": "K" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "Time" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "V" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['time_label'] or exchange.alarmAnalysis.fmt.dayClock(value['time_ms'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": {}, + "type": "ia.display.label" + } + ], + "meta": { + "name": "RowTime" + }, + "props": { + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "K" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "Source" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "V" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['source'] or '--'\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "overflowWrap": "anywhere" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "RowSource" + }, + "props": { + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "K" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "Area" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "V" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['area'] or '--'\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": {}, + "type": "ia.display.label" + } + ], + "meta": { + "name": "RowArea" + }, + "props": { + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "K" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "Ack by" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "V" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['ack_user'] or '--'\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": {}, + "type": "ia.display.label" + } + ], + "meta": { + "name": "RowAck" + }, + "props": { + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "CardCore" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblPropsTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Event properties" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "TblProps" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.data": { + "binding": { + "config": { + "path": "view.custom.props" + }, + "transforms": [ + { + "code": "\tout = []\n\tfor p in (value or []):\n\t\ttry:\n\t\t\tv = p['value']\n\t\t\tout.append({'name': '%s' % p['name'], 'value': '' if v is None else '%s' % v})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "name", + "header": { + "title": "Property" + }, + "render": "string", + "sortable": false, + "strictWidth": true, + "width": 180 + }, + { + "field": "value", + "header": { + "title": "Value" + }, + "render": "string", + "sortable": false + } + ], + "emptyMessage": { + "noData": { + "text": "No extended properties for this event" + } + }, + "pager": { + "bottom": false, + "top": false + } + }, + "type": "ia.display.table" + } + ], + "meta": { + "name": "CardProps" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "BodyCol" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\treturn value is not None", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "EmptyWrap" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.event" + }, + "transforms": [ + { + "code": "\treturn value is None", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "info", + "message": "No event selected" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.closePopup('exchange.alarmAnalysis.eventDetail')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "BtnClose" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "primary": true, + "text": "Close" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "FooterRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "flex-end" + }, + "type": "ia.container.flex" + } + ], + "custom": {}, + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/page-bg", + "gap": "10px", + "padding": "16px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/HealthScore/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/HealthScore/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/HealthScore/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/HealthScore/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/HealthScore/view.json new file mode 100644 index 0000000..0e6da0b --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/HealthScore/view.json @@ -0,0 +1,318 @@ +{ + "custom": {}, + "params": { + "health": null + }, + "propConfig": { + "params.health": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 540, + "width": 540 + } + }, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "GradeChip" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.style.classes": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tg = value['grade']\n\texcept:\n\t\tg = None\n\treturn 'exchange/alarm-analysis/grade/' + (g.lower() if g in ['A', 'B', 'C', 'D', 'F'] else 'na')", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['grade'] or 'N/A'\n\texcept:\n\t\treturn 'N/A'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontSize": "38px", + "paddingBottom": "4px", + "paddingLeft": "18px", + "paddingRight": "18px", + "paddingTop": "4px", + "textAlign": "center" + } + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "meta": { + "name": "LblTitle" + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/big" + }, + "text": "Alarm Health Score" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "LblScore" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ts = value['score']\n\texcept:\n\t\ts = None\n\tif s is None:\n\t\treturn 'No data in selected range'\n\treturn 'Overall score %s / 100' % exchange.alarmAnalysis.fmt.num(s, 1)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "TitleCol" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "justify": "center" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "HeaderRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "14px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblSubsTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Component scores" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "RepSubs" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tsubs = value['subs'] or []\n\texcept:\n\t\tsubs = []\n\tout = []\n\tfor s in subs:\n\t\ttry:\n\t\t\tout.append({'key': s['key'], 'label': s['label'], 'score': s['score'], 'weight': s['weight'], 'detail': s['detail']})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "elementPosition": { + "basis": "64px", + "grow": 0, + "shrink": 0 + }, + "path": "Exchange/AlarmAnalysis/Components/ScoreBar", + "style": { + "overflowY": "auto" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + }, + { + "meta": { + "name": "EmptyWrap" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.health" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn not (value and value['subs'])\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "assessment", + "message": "No health data in the selected range" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "CardSubs" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblCaption" + }, + "position": { + "grow": 1, + "shrink": 1 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "Weighted ISA-18.2 composite: rate, flood, chattering, standing, priority mix" + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.closePopup('exchange.alarmAnalysis.healthScore')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "BtnClose" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "primary": true, + "text": "Close" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "FooterRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "space-between", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "custom": {}, + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/page-bg", + "gap": "12px", + "padding": "16px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/ShiftReport/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/ShiftReport/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/ShiftReport/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/ShiftReport/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/ShiftReport/view.json new file mode 100644 index 0000000..51b31c1 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Popups/ShiftReport/view.json @@ -0,0 +1,210 @@ +{ + "custom": { + "report": null + }, + "params": { + "refreshToken": null + }, + "propConfig": { + "custom.report": { + "binding": { + "config": { + "expression": "{view.params.refreshToken}" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tr = exchange.alarmAnalysis.alarms.getShiftReportText()\n\t\tif r and r['text']:\n\t\t\treturn r\n\texcept:\n\t\tpass\n\treturn {'text': 'Report unavailable', 'start_ms': 0, 'end_ms': 0, 'label': ''}", + "type": "script" + } + ], + "type": "expr" + } + }, + "params.refreshToken": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 560, + "width": 620 + } + }, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblTitle" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.report" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['label'] or 'Shift Report'\n\texcept:\n\t\treturn 'Shift Report'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/big" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "LblWindow" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.report" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ts = value['start_ms']\n\t\te = value['end_ms']\n\texcept:\n\t\treturn ''\n\tif not s or not e:\n\t\treturn ''\n\treturn '%s - %s' % (exchange.alarmAnalysis.fmt.dayClock(s), exchange.alarmAnalysis.fmt.dayClock(e))", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "HeaderCol" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblText" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.report" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['text'] or 'Report unavailable'\n\texcept:\n\t\treturn 'Report unavailable'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "fontFamily": "monospace", + "fontSize": "12px", + "lineHeight": "1.5", + "whiteSpace": "pre-wrap" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "CardText" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "overflowY": "auto" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.closePopup('exchange.alarmAnalysis.shiftReport')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "BtnClose" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "primary": true, + "text": "Close" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "FooterRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "flex-end" + }, + "type": "ia.container.flex" + } + ], + "custom": {}, + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/page-bg", + "gap": "12px", + "padding": "16px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/FilterBar/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/FilterBar/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/FilterBar/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/FilterBar/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/FilterBar/view.json new file mode 100644 index 0000000..12f6d50 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/FilterBar/view.json @@ -0,0 +1,282 @@ +{ + "custom": {}, + "params": { + "bundle": null, + "query": null + }, + "propConfig": { + "params.bundle": { + "paramDirection": "input", + "persistent": false + }, + "params.query": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 56, + "width": 1600 + } + }, + "root": { + "children": [ + { + "meta": { + "name": "DdPriority" + }, + "position": { + "basis": "170px", + "shrink": 0 + }, + "propConfig": { + "props.options": { + "binding": { + "config": { + "path": "view.params.bundle.meta.priorities_seen" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn [{'value': p['value'], 'label': p['label']} for p in (value or [])]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + }, + "props.value": { + "binding": { + "config": { + "path": "view.params.query.priorities" + }, + "type": "property" + } + } + }, + "props": { + "multiSelect": true, + "placeholder": { + "text": "Priority" + }, + "style": { + "maxWidth": "280px" + }, + "wrapMultiSelectValues": true + }, + "type": "ia.input.dropdown" + }, + { + "meta": { + "name": "DdArea" + }, + "position": { + "basis": "170px", + "shrink": 0 + }, + "propConfig": { + "props.options": { + "binding": { + "config": { + "path": "view.params.bundle.meta.areas" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn [{'value': a, 'label': a} for a in (value or [])]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + }, + "props.value": { + "binding": { + "config": { + "path": "view.params.query.areas" + }, + "type": "property" + } + } + }, + "props": { + "multiSelect": true, + "placeholder": { + "text": "Area" + }, + "style": { + "maxWidth": "280px" + }, + "wrapMultiSelectValues": true + }, + "type": "ia.input.dropdown" + }, + { + "meta": { + "name": "DdState" + }, + "position": { + "basis": "170px", + "shrink": 0 + }, + "propConfig": { + "props.value": { + "binding": { + "config": { + "path": "view.params.query.states" + }, + "type": "property" + } + } + }, + "props": { + "multiSelect": true, + "options": [ + { + "label": "Active", + "value": "active" + }, + { + "label": "Cleared", + "value": "cleared" + }, + { + "label": "Acked", + "value": "acked" + }, + { + "label": "Unacked", + "value": "unacked" + } + ], + "placeholder": { + "text": "State" + }, + "style": { + "maxWidth": "280px" + }, + "wrapMultiSelectValues": true + }, + "type": "ia.input.dropdown" + }, + { + "meta": { + "name": "TxtSearch" + }, + "position": { + "basis": "240px", + "shrink": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.query.search" + }, + "type": "property" + } + } + }, + "props": { + "deferUpdates": true, + "placeholder": "Search source/label..." + }, + "type": "ia.input.text-field" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\ttry:\n\t\tfilters = {\n\t\t\t'priorities': list(self.getSibling('DdPriority').props.value or []),\n\t\t\t'areas': list(self.getSibling('DdArea').props.value or []),\n\t\t\t'states': list(self.getSibling('DdState').props.value or []),\n\t\t\t'search': str(self.getSibling('TxtSearch').props.text or '')\n\t\t}\n\texcept:\n\t\tfilters = {'priorities': [], 'areas': [], 'states': [], 'search': ''}\n\tsystem.perspective.sendMessage('exchange.alarmAnalysis.applyFilters', payload = filters, scope = 'page')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "BtnApply" + }, + "position": { + "basis": "80px", + "shrink": 0 + }, + "props": { + "primary": true, + "text": "Apply" + }, + "type": "ia.input.button" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\ttry:\n\t\tself.getSibling('DdPriority').props.value = []\n\t\tself.getSibling('DdArea').props.value = []\n\t\tself.getSibling('DdState').props.value = []\n\t\tself.getSibling('TxtSearch').props.text = ''\n\texcept:\n\t\tpass\n\tsystem.perspective.sendMessage('exchange.alarmAnalysis.clearFilters', payload = {}, scope = 'page')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "BtnClear" + }, + "position": { + "basis": "80px", + "shrink": 0 + }, + "props": { + "primary": false, + "text": "Clear" + }, + "type": "ia.input.button" + }, + { + "meta": { + "name": "LblEventCount" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tn = value['meta']['event_count']\n\texcept:\n\t\tn = None\n\tif n is None:\n\t\treturn ''\n\treturn '%s events in range' % exchange.alarmAnalysis.fmt.num(n)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "textStyle": { + "textAlign": "right" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "root" + }, + "props": { + "alignItems": "center", + "direction": "row", + "style": { + "classes": "exchange/alarm-analysis/toolbar", + "gap": "8px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/Header/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/Header/resource.json new file mode 100644 index 0000000..8fcbc6e --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/Header/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "ad9c36584854ed67b341f681e7992b6264caf658ed334102fa088a1cc84012fd", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:47:54Z" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/Header/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/Header/view.json new file mode 100644 index 0000000..09f1a6c --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Shell/Header/view.json @@ -0,0 +1,704 @@ +{ + "custom": {}, + "params": { + "bundle": null, + "query": null + }, + "propConfig": { + "params.bundle": { + "paramDirection": "input", + "persistent": false + }, + "params.query": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 165, + "width": 1600 + } + }, + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblTitle" + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/big" + }, + "text": "Alarm Analysis" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "LblSubtitle" + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "" + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "Titles" + }, + "position": { + "shrink": 0 + }, + "props": { + "direction": "column", + "justify": "center" + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "ChipTruncated" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['meta']['truncated'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "color": "#E8883A" + }, + "text": "data truncated \u2014 narrow the range" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ChipError" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['meta']['error'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['meta']['error'] or ''\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "color": "#D64550" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "Chips" + }, + "position": { + "grow": 1 + }, + "props": { + "alignItems": "center", + "justify": "flex-end", + "style": { + "gap": "8px", + "paddingRight": "12px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.sendMessage('exchange.alarmAnalysis.setRange', payload = {'preset': event.value}, scope = 'page')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "DdRange" + }, + "position": { + "basis": "150px", + "shrink": 0 + }, + "propConfig": { + "props.value": { + "binding": { + "config": { + "path": "view.params.query.rangePreset" + }, + "type": "property" + } + } + }, + "props": { + "options": [ + { + "label": "Last 4 hours", + "value": "4h" + }, + { + "label": "Last 8 hours", + "value": "8h" + }, + { + "label": "Last 24 hours", + "value": "24h" + }, + { + "label": "Last 7 days", + "value": "7d" + } + ], + "placeholder": { + "text": "Range" + } + }, + "type": "ia.input.dropdown" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.sendMessage('exchange.alarmAnalysis.refresh', payload = {}, scope = 'page')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "BtnRefresh" + }, + "position": { + "basis": "90px", + "shrink": 0 + }, + "props": { + "image": { + "height": 15 + }, + "primary": false, + "text": "Refresh" + }, + "type": "ia.input.button" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\tsystem.perspective.openPopup('exchange.alarmAnalysis.shiftReport', 'Exchange/AlarmAnalysis/Popups/ShiftReport', params = {'refreshToken': self.view.params.query.refreshToken}, showCloseIcon = True, draggable = True)" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "BtnShiftReport" + }, + "position": { + "basis": "120px", + "shrink": 0 + }, + "props": { + "image": { + "height": 15 + }, + "text": "Shift Report" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "Controls" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "RowTop" + }, + "position": { + "basis": "55px", + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "space-between", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "Gauge" + }, + "position": { + "basis": "230px", + "shrink": 0 + }, + "propConfig": { + "props.params.health": { + "binding": { + "config": { + "path": "view.params.bundle.health" + }, + "type": "property" + } + } + }, + "props": { + "path": "Exchange/AlarmAnalysis/Components/HealthGauge", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiActivations" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['activations']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.num(value['kpis']['activations']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Activations" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiRate" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['rate_per_hr']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.num(value['kpis']['rate_per_hr']['value'], 1)\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Rate", + "unit": "/hr" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiActiveNow" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['active_now']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.num(value['kpis']['active_now']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Active Now" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiUnacked" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['unacked_now']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.num(value['kpis']['unacked_now']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Unacked" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiMtta" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['mtta_ms']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.dur(value['kpis']['mtta_ms']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "MTTA" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiFlood" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['flood_pct']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.pct(value['kpis']['flood_pct']['value'], 1)\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Flood Time" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiChatter" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['kpis']['chatter_count']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.num(value['kpis']['chatter_count']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Chattering" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard", + "style": { + "overflow": "hidden" + } + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "RowKpis" + }, + "position": { + "grow": 1 + }, + "props": { + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/toolbar", + "gap": "8px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Analysis/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Analysis/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Analysis/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Analysis/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Analysis/view.json new file mode 100644 index 0000000..835826b --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Analysis/view.json @@ -0,0 +1,2463 @@ +{ + "custom": {}, + "params": { + "bundle": null + }, + "propConfig": { + "params.bundle": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 760, + "width": 1600 + } + }, + "root": { + "children": [ + { + "meta": { + "name": "EmptyAll" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn (not value) or value['meta']['event_count'] == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "inbox", + "message": "No alarm events in the selected range" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "meta": { + "name": "HmTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Activations by Hour & Day of Week" + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblMon" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Mon" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "RepMon" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx} for c in hm['rows'][0]][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "Exchange/AlarmAnalysis/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "RowMon" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblTue" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Tue" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "RepTue" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx} for c in hm['rows'][1]][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "Exchange/AlarmAnalysis/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "RowTue" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblWed" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Wed" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "RepWed" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx} for c in hm['rows'][2]][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "Exchange/AlarmAnalysis/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "RowWed" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblThu" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Thu" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "RepThu" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx} for c in hm['rows'][3]][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "Exchange/AlarmAnalysis/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "RowThu" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblFri" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Fri" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "RepFri" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx} for c in hm['rows'][4]][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "Exchange/AlarmAnalysis/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "RowFri" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblSat" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Sat" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "RepSat" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx} for c in hm['rows'][5]][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "Exchange/AlarmAnalysis/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "RowSat" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblSun" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "10px", + "paddingRight": "6px", + "textAlign": "right" + }, + "text": "Sun" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "RepSun" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\thm = value['heatmap']\n\t\tmx = int(hm['max_count'] or 0)\n\t\treturn [{'count': int(c or 0), 'max': mx} for c in hm['rows'][6]][:24]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "elementPosition": { + "basis": "0px" + }, + "path": "Exchange/AlarmAnalysis/Components/HeatmapCell", + "style": { + "overflow": "hidden" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + } + ], + "meta": { + "name": "RowSun" + }, + "position": { + "grow": 1 + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "AxSpacer" + }, + "position": { + "basis": "34px", + "shrink": 0 + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Ax00" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "9px" + }, + "text": "0" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Ax03" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "9px" + }, + "text": "3" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Ax06" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "9px" + }, + "text": "6" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Ax09" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "9px" + }, + "text": "9" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Ax12" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "9px" + }, + "text": "12" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Ax15" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "9px" + }, + "text": "15" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Ax18" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "9px" + }, + "text": "18" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "Ax21" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "fontSize": "9px" + }, + "text": "21" + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "HourAxis" + }, + "position": { + "basis": "14px", + "shrink": 0 + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "HmGrid" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "CardHeatmap" + }, + "position": { + "basis": "0px", + "grow": 3 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "PTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Priority Mix vs ISA 80/15/5" + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "Donut" + }, + "position": { + "basis": "95%" + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tp = value['priority']['pct']\n\t\treturn (p['low'] or 0) + (p['medium'] or 0) + (p['high'] or 0) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tp = value['priority']['pct']\n\t\treturn [{'label': 'Low', 'value': round(float(p['low'] or 0), 1)}, {'label': 'Medium', 'value': round(float(p['medium'] or 0), 1)}, {'label': 'High', 'value': round(float(p['high'] or 0), 1)}]\n\texcept:\n\t\treturn []", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "colors": [ + "#5B9BD5", + "#E5C453", + "#E8883A" + ], + "cutoutRadius": 60, + "showLabels": false, + "showLegend": false, + "tooltipFormat": "{category}: {value.value.formatNumber('#.0')}%" + }, + "type": "ia.chart.pie" + }, + { + "meta": { + "name": "DonutEmpty" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tp = value['priority']['pct']\n\t\treturn (p['low'] or 0) + (p['medium'] or 0) + (p['high'] or 0) <= 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "pie_chart", + "message": "No priority data in range" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "PieWrap" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "ChipLow" + }, + "position": { + "basis": "72px", + "shrink": 0 + }, + "props": { + "style": { + "borderRadius": "10px", + "classes": "exchange/alarm-analysis/priority/low", + "fontSize": "11px", + "padding": "2px 8px", + "textAlign": "center" + }, + "text": "Low" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ValLow" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tpr = value['priority']\n\t\treturn '%s now vs %s target' % (exchange.alarmAnalysis.fmt.pct(pr['pct']['low'], 1), exchange.alarmAnalysis.fmt.pct(pr['target']['low'], 0))\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "TargetLow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "ChipMedium" + }, + "position": { + "basis": "72px", + "shrink": 0 + }, + "props": { + "style": { + "borderRadius": "10px", + "classes": "exchange/alarm-analysis/priority/medium", + "fontSize": "11px", + "padding": "2px 8px", + "textAlign": "center" + }, + "text": "Medium" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ValMedium" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tpr = value['priority']\n\t\treturn '%s now vs %s target' % (exchange.alarmAnalysis.fmt.pct(pr['pct']['medium'], 1), exchange.alarmAnalysis.fmt.pct(pr['target']['medium'], 0))\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "TargetMedium" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "ChipHigh" + }, + "position": { + "basis": "72px", + "shrink": 0 + }, + "props": { + "style": { + "borderRadius": "10px", + "classes": "exchange/alarm-analysis/priority/high", + "fontSize": "11px", + "padding": "2px 8px", + "textAlign": "center" + }, + "text": "High" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ValHigh" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tpr = value['priority']\n\t\treturn '%s now vs %s target' % (exchange.alarmAnalysis.fmt.pct(pr['pct']['high'], 1), exchange.alarmAnalysis.fmt.pct(pr['target']['high'], 0))\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "TargetHigh" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "Targets" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "justify": "center", + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "PBody" + }, + "position": { + "grow": 1 + }, + "props": { + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "PCaption" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tsad = value['priority']['sum_abs_dev']\n\t\tif sad is None:\n\t\t\treturn ''\n\t\treturn 'Deviation from ISA 80/15/5 target: %s pts' % exchange.alarmAnalysis.fmt.num(sad, 1)\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "CardPriority" + }, + "position": { + "basis": "0px", + "grow": 2 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "8px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "RowTop" + }, + "position": { + "basis": "300px", + "grow": 1, + "shrink": 0 + }, + "props": { + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "MtTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Response Times (MTTA / MTTR)" + }, + "type": "ia.display.label" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblMtta" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "MTTA (mean time to acknowledge)" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ValMtta" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.dur(value['mtta_mttr']['mtta']['mean_ms'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/kpi" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "SubMtta" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tm = value['mtta_mttr']['mtta']\n\t\treturn 'median %s over %s acks' % (exchange.alarmAnalysis.fmt.dur(m['median_ms']), exchange.alarmAnalysis.fmt.num(m['count']))\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "ColMtta" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblMttr" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "MTTR (mean time to resolve)" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "ValMttr" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.dur(value['mtta_mttr']['mttr']['mean_ms'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/kpi" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "SubMttr" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tm = value['mtta_mttr']['mttr']\n\t\treturn 'median %s over %s clears' % (exchange.alarmAnalysis.fmt.dur(m['median_ms']), exchange.alarmAnalysis.fmt.num(m['count']))\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "ColMttr" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column" + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "MtHead" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "gap": "16px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "TrendChart" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tfor r in (value['mtta_mttr']['trend'] or []):\n\t\t\tif r['mtta_ms'] is not None or r['mttr_ms'] is not None:\n\t\t\t\treturn True\n\texcept:\n\t\tpass\n\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.dataSources.trend": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\timport time\n\tout = []\n\ttry:\n\t\trows = value['mtta_mttr']['trend'] or []\n\texcept:\n\t\trows = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tt0 = r['t0']\n\t\t\tif t0 is None:\n\t\t\t\tcontinue\n\t\t\tts = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(t0 / 1000.0))\n\t\t\ta = r['mtta_ms']\n\t\t\tc = r['mttr_ms']\n\t\t\tout.append({'t': ts, 'mtta': None if a is None else round(a / 60000.0, 2), 'mttr': None if c is None else round(c / 60000.0, 2)})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "dataSources": {}, + "legend": { + "labels": { + "font": { + "size": 11 + } + }, + "markers": { + "height": 12, + "width": 12 + } + }, + "scrollBars": { + "horizontal": { + "enabled": false + }, + "vertical": { + "enabled": false + } + }, + "series": [ + { + "candlestick": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + } + }, + "high": { + "x": "", + "y": "" + }, + "low": { + "x": "", + "y": "" + }, + "open": { + "x": "", + "y": "" + } + }, + "column": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "height": null, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "width": null + }, + "open": { + "x": "", + "y": "" + } + }, + "data": { + "source": "trend", + "x": "t", + "y": "mtta" + }, + "defaultState": { + "visible": true + }, + "hiddenInLegend": false, + "label": { + "text": "MTTA" + }, + "line": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": true, + "fill": { + "color": "#3B7DD8", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 3, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY.formatNumber('#,###.#')} min[/]" + } + } + ], + "connect": false, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "#3B7DD8", + "dashArray": "", + "opacity": 1, + "width": 2 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "name": "mtta", + "render": "line", + "stepLine": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": true, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY.formatNumber('#,###.#')} min[/]" + }, + "visible": true, + "xAxis": "t", + "yAxis": "y", + "zIndex": 0 + }, + { + "candlestick": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + } + }, + "high": { + "x": "", + "y": "" + }, + "low": { + "x": "", + "y": "" + }, + "open": { + "x": "", + "y": "" + } + }, + "column": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "height": null, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "width": null + }, + "open": { + "x": "", + "y": "" + } + }, + "data": { + "source": "trend", + "x": "t", + "y": "mttr" + }, + "defaultState": { + "visible": true + }, + "hiddenInLegend": false, + "label": { + "text": "MTTR" + }, + "line": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": true, + "fill": { + "color": "#E8883A", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 3, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY.formatNumber('#,###.#')} min[/]" + } + } + ], + "connect": false, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "#E8883A", + "dashArray": "", + "opacity": 1, + "width": 2 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "name": "mttr", + "render": "line", + "stepLine": { + "appearance": { + "bullets": [], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY.formatNumber('#,###.#')} min[/]" + }, + "visible": true, + "xAxis": "t", + "yAxis": "y", + "zIndex": 0 + } + ], + "xAxes": [ + { + "appearance": { + "font": { + "size": 10, + "weight": 500 + }, + "grid": { + "color": "", + "dashArray": "", + "minDistance": 60, + "opacity": 0.3, + "position": 0.5 + }, + "inside": false, + "labels": { + "color": "", + "horizontalCenter": "middle", + "opacity": 1, + "rotation": 0, + "verticalCenter": "middle" + }, + "opposite": false + }, + "category": { + "break": { + "enabled": false, + "endCategory": "", + "size": 0.05, + "startCategory": "" + } + }, + "date": { + "baseInterval": { + "count": 1, + "enabled": false, + "skipEmptyPeriods": false, + "timeUnit": "hour" + }, + "break": { + "enabled": false, + "endDate": "", + "size": 0.05, + "startDate": "" + }, + "format": "M/d HH:mm", + "inputFormat": "yyyy-MM-dd HH:mm:ss", + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "inversed": false, + "label": { + "color": "", + "enabled": false, + "text": "" + }, + "name": "t", + "render": "date", + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "pointerLength": 4, + "text": "" + }, + "value": { + "break": { + "enabled": false, + "endValue": 100, + "size": 0.05, + "startValue": 0 + }, + "format": "#,###.##", + "logarithmic": false, + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "visible": true + } + ], + "yAxes": [ + { + "appearance": { + "font": { + "size": 10, + "weight": 500 + }, + "grid": { + "color": "", + "dashArray": "", + "minDistance": null, + "opacity": 0.3, + "position": 0.5 + }, + "inside": false, + "labels": { + "color": "", + "horizontalCenter": "middle", + "opacity": 1, + "rotation": 0, + "verticalCenter": "middle" + }, + "opposite": false + }, + "category": { + "break": { + "enabled": false, + "endCategory": "", + "size": 0.05, + "startCategory": "" + } + }, + "date": { + "baseInterval": { + "count": 1, + "enabled": false, + "skipEmptyPeriods": false, + "timeUnit": "hour" + }, + "break": { + "enabled": false, + "endDate": "", + "size": 0.05, + "startDate": "" + }, + "format": "M/d/yyyy HH:mm:ss", + "inputFormat": "yyyy-MM-dd kk:mm:ss", + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "inversed": false, + "label": { + "color": "", + "enabled": true, + "text": "minutes" + }, + "name": "y", + "render": "value", + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": false, + "pointerLength": 4, + "text": "" + }, + "value": { + "break": { + "enabled": false, + "endValue": 100, + "size": 0.05, + "startValue": 0 + }, + "format": "#,###.#", + "logarithmic": false, + "range": { + "max": "", + "min": 0, + "useStrict": false + } + }, + "visible": true + } + ] + }, + "type": "ia.chart.xy" + }, + { + "meta": { + "name": "TrendEmpty" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tfor r in (value['mtta_mttr']['trend'] or []):\n\t\t\tif r['mtta_ms'] is not None or r['mttr_ms'] is not None:\n\t\t\t\treturn False\n\texcept:\n\t\tpass\n\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "timeline", + "message": "Not enough ack/clear data for a trend" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "CardMtta" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "FlTitle" + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Flood Episodes" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "FlCaption" + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tp = value['floods']['pct_time_in_flood']\n\t\tif p is None:\n\t\t\treturn ''\n\t\treturn '%s of window time in flood' % exchange.alarmAnalysis.fmt.pct(p, 1)\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "FlTitleRow" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "justify": "space-between" + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "FloodTable" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['floods']['episodes'] or []) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\tout = []\n\ttry:\n\t\teps = value['floods']['episodes'] or []\n\texcept:\n\t\teps = []\n\tfor e in eps:\n\t\ttry:\n\t\t\ttop = e['top_source_label'] or ''\n\t\t\tif top and e['top_source_count'] is not None:\n\t\t\t\ttop = '%s (%s)' % (top, exchange.alarmAnalysis.fmt.num(e['top_source_count']))\n\t\t\tout.append({'start': exchange.alarmAnalysis.fmt.dayClock(e['start_ms']), 'duration': exchange.alarmAnalysis.fmt.dur(e['duration_ms']), 'events': exchange.alarmAnalysis.fmt.num(e['event_count']), 'peak': exchange.alarmAnalysis.fmt.num(e['peak_bin_count']), 'top': top or '--', 'area': e['top_area'] or '--'})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "align": "center", + "editable": false, + "field": "start", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Start" + }, + "justify": "auto", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 1.3 + }, + { + "align": "center", + "editable": false, + "field": "duration", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Duration" + }, + "justify": "auto", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 1 + }, + { + "align": "center", + "editable": false, + "field": "events", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Events" + }, + "justify": "right", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 0.7 + }, + { + "align": "center", + "editable": false, + "field": "peak", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Peak / 10 min" + }, + "justify": "right", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 0.9 + }, + { + "align": "center", + "editable": false, + "field": "top", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Top Contributor" + }, + "justify": "auto", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 2 + }, + { + "align": "center", + "editable": false, + "field": "area", + "header": { + "align": "center", + "justify": "left", + "style": { + "classes": "" + }, + "title": "Area" + }, + "justify": "auto", + "render": "string", + "resizable": true, + "sort": "none", + "sortable": false, + "strictWidth": false, + "visible": true, + "width": 1 + } + ], + "pager": { + "bottom": false + }, + "rows": { + "height": 34 + }, + "selection": { + "enableRowSelection": false + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "FloodEmpty" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['floods']['episodes'] or []) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "waves", + "message": "No flood episodes in range" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "CardFloods" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "8px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "RowBottom" + }, + "position": { + "basis": "340px", + "grow": 1, + "shrink": 0 + }, + "props": { + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "Content" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value) and value['meta']['event_count'] > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/BadActors/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/BadActors/resource.json new file mode 100644 index 0000000..012eddf --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/BadActors/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": { + "lastModificationSignature": "7504c7695ab45f2fc1d0d6dc8071e96746ac068f99e878d3ca1dcf804c9eea76", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T20:38:41Z" + } + } +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/BadActors/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/BadActors/view.json new file mode 100644 index 0000000..ece532b --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/BadActors/view.json @@ -0,0 +1,1107 @@ +{ + "custom": {}, + "params": { + "bundle": null, + "focus": null, + "query": null + }, + "propConfig": { + "params.bundle": { + "paramDirection": "input", + "persistent": false + }, + "params.focus": { + "paramDirection": "input", + "persistent": false + }, + "params.query": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "width": 1600 + } + }, + "root": { + "children": [ + { + "meta": { + "name": "WholeEmpty" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif not value:\n\t\t\treturn True\n\t\treturn int(value['meta']['event_count'] or 0) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "inbox", + "message": "No alarm events in the selected range" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblParetoTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Top Sources by Activations" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "LblParetoCaption" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\trows = value['pareto']['rows'] or []\n\t\ttotal = value['pareto']['total_activations'] or 0\n\texcept:\n\t\trows = []\n\t\ttotal = 0\n\tif not rows or not total:\n\t\treturn 'No activations in the selected range'\n\tn = min(len(rows), 10)\n\ttry:\n\t\tcum = rows[n - 1]['cum_pct']\n\texcept:\n\t\tcum = None\n\tif cum is None:\n\t\treturn 'Top %d sources of %s activations' % (n, exchange.alarmAnalysis.fmt.num(total))\n\treturn 'Top %d sources = %s of all %s activations' % (n, exchange.alarmAnalysis.fmt.pct(cum), exchange.alarmAnalysis.fmt.num(total))", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onRowClick": { + "config": { + "script": "\ttry:\n\t\trow = event.value\n\t\tsrc = row['source']\n\t\tlbl = row['label']\n\texcept:\n\t\treturn\n\ttry:\n\t\tsrc = src['value']\n\texcept:\n\t\tpass\n\ttry:\n\t\tlbl = lbl['value']\n\texcept:\n\t\tpass\n\tif not src:\n\t\treturn\n\ttry:\n\t\tsystem.perspective.openPopup('exchange.alarmAnalysis.alarmDetail', 'Exchange/AlarmAnalysis/Popups/AlarmDetail', params = {'source': src, 'label': lbl, 'startMs': self.view.params.query.startMs, 'endMs': self.view.params.query.endMs}, showCloseIcon = True, draggable = True)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "TblPareto" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['pareto']['rows'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "struct": { + "bundle": "{view.params.bundle}", + "focus": "{view.params.focus}" + }, + "waitOnAll": false + }, + "transforms": [ + { + "code": "\ttry:\n\t\trows = value['bundle']['pareto']['rows'] or []\n\texcept:\n\t\trows = []\n\ttry:\n\t\ttotal = value['bundle']['pareto']['total_activations'] or 0\n\texcept:\n\t\ttotal = 0\n\ttry:\n\t\tfocus = value['focus'] or ''\n\texcept:\n\t\tfocus = ''\n\thi = {'backgroundColor': 'rgba(59, 125, 216, 0.18)', 'fontWeight': 'bold'}\n\tpri = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tout = []\n\trank = 0\n\tfor r in rows[:10]:\n\t\ttry:\n\t\t\trank = rank + 1\n\t\t\tsrc = r['source'] or ''\n\t\t\tcnt = r['count'] or 0\n\t\t\td = {}\n\t\t\td['rank'] = rank\n\t\t\td['source'] = src\n\t\t\td['label'] = r['label'] or src\n\t\t\td['area'] = r['area'] or ''\n\t\t\td['count'] = cnt\n\t\t\tif total > 0:\n\t\t\t\td['share'] = round(cnt * 100.0 / total, 1)\n\t\t\telse:\n\t\t\t\td['share'] = 0\n\t\t\ttry:\n\t\t\t\td['cum'] = exchange.alarmAnalysis.fmt.pct(r['cum_pct'])\n\t\t\texcept:\n\t\t\t\td['cum'] = '--'\n\t\t\tif focus and src == focus:\n\t\t\t\tfor k in ('label', 'area', 'count', 'cum'):\n\t\t\t\t\td[k] = {'value': d[k], 'style': hi}\n\t\t\tpname = r['priority_name'] or ''\n\t\t\tif pname in pri:\n\t\t\t\td['priority'] = {'value': pname, 'style': {'classes': 'exchange/alarm-analysis/priority-soft/' + pname.lower()}}\n\t\t\telse:\n\t\t\t\td['priority'] = pname\n\t\t\tout.append(d)\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "expr-struct" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "source", + "header": { + "title": "source" + }, + "visible": false + }, + { + "field": "rank", + "header": { + "justify": "center", + "title": "#" + }, + "justify": "center", + "width": 0.5 + }, + { + "field": "label", + "header": { + "title": "Source" + }, + "width": 3 + }, + { + "field": "area", + "header": { + "title": "Area" + }, + "width": 1.5 + }, + { + "field": "priority", + "header": { + "justify": "center", + "title": "Priority" + }, + "justify": "center", + "width": 1.3 + }, + { + "field": "count", + "header": { + "justify": "right", + "title": "Count" + }, + "justify": "right", + "render": "number", + "width": 1 + }, + { + "field": "share", + "header": { + "title": "Share of Total" + }, + "number": "progress", + "progressBar": { + "bar": { + "color": "#3B7DD8" + }, + "max": 100, + "min": 0 + }, + "render": "number", + "width": 2.4 + }, + { + "field": "cum", + "header": { + "justify": "right", + "title": "Cumulative" + }, + "justify": "right", + "width": 1.3 + } + ], + "pager": { + "bottom": false + }, + "rows": { + "height": 34 + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "EmptyPareto" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn not value['pareto']['rows']\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "bar_chart", + "message": "No activations in the selected range" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "CardPareto" + }, + "position": { + "basis": "320px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblFocus" + }, + "position": { + "basis": "auto", + "grow": 0, + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "struct": { + "bundle": "{view.params.bundle}", + "focus": "{view.params.focus}" + }, + "waitOnAll": false + }, + "transforms": [ + { + "code": "\ttry:\n\t\tsrc = value['focus'] or ''\n\texcept:\n\t\tsrc = ''\n\tif not src:\n\t\treturn ''\n\tlabel = src\n\ttry:\n\t\tb = value['bundle']\n\t\tlists = [b['pareto']['rows'], b['chattering'], b['standing']['rows'], b['fleeting']['sources']]\n\t\tfor rows in lists:\n\t\t\tfor r in (rows or []):\n\t\t\t\tif r['source'] == src:\n\t\t\t\t\tlabel = r['label'] or src\n\t\t\t\t\traise StopIteration\n\texcept:\n\t\tpass\n\treturn 'Focused: %s' % label", + "type": "script" + } + ], + "type": "expr-struct" + } + } + }, + "props": { + "style": { + "whiteSpace": "nowrap" + } + }, + "type": "ia.display.label" + }, + { + "events": { + "dom": { + "onClick": { + "config": { + "script": "\tsystem.perspective.sendMessage('exchange.alarmAnalysis.clearFocus', payload = {}, scope = 'page')" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "IcoClearFocus" + }, + "position": { + "basis": "20px", + "shrink": 0 + }, + "props": { + "path": "material/close", + "style": { + "cursor": "pointer", + "fontSize": "16px" + } + }, + "type": "ia.display.icon" + } + ], + "meta": { + "name": "ChipFocus" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "classes": "exchange/alarm-analysis/chip/flat", + "gap": "6px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "LblFocusHint" + }, + "position": { + "grow": 1 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "Matching rows are highlighted below \u2014 click the X to clear" + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "RowFocus" + }, + "position": { + "basis": "36px", + "shrink": 0 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "expression": "{view.params.focus} != \"\"" + }, + "type": "expr" + } + } + }, + "props": { + "alignItems": "center", + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblChatterTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Chattering" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "LblChatterCaption" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "Rapid-cycling alarms (>10/hr, median gap <= 2 min)" + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onRowClick": { + "config": { + "script": "\ttry:\n\t\trow = event.value\n\t\tsrc = row['source']\n\t\tlbl = row['label']\n\texcept:\n\t\treturn\n\ttry:\n\t\tsrc = src['value']\n\texcept:\n\t\tpass\n\ttry:\n\t\tlbl = lbl['value']\n\texcept:\n\t\tpass\n\tif not src:\n\t\treturn\n\ttry:\n\t\tsystem.perspective.openPopup('exchange.alarmAnalysis.alarmDetail', 'Exchange/AlarmAnalysis/Popups/AlarmDetail', params = {'source': src, 'label': lbl, 'startMs': self.view.params.query.startMs, 'endMs': self.view.params.query.endMs}, showCloseIcon = True, draggable = True)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "TblChatter" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['chattering'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "struct": { + "bundle": "{view.params.bundle}", + "focus": "{view.params.focus}" + }, + "waitOnAll": false + }, + "transforms": [ + { + "code": "\ttry:\n\t\trows = value['bundle']['chattering'] or []\n\texcept:\n\t\trows = []\n\ttry:\n\t\tfocus = value['focus'] or ''\n\texcept:\n\t\tfocus = ''\n\thi = {'backgroundColor': 'rgba(59, 125, 216, 0.18)', 'fontWeight': 'bold'}\n\tpri = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tout = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tsrc = r['source'] or ''\n\t\t\td = {}\n\t\t\td['source'] = src\n\t\t\td['label'] = r['label'] or src\n\t\t\td['area'] = r['area'] or ''\n\t\t\td['count'] = r['count'] or 0\n\t\t\td['per_hour'] = exchange.alarmAnalysis.fmt.num(r['per_hour'], 1)\n\t\t\tgap = r['median_gap_s']\n\t\t\tif gap is None:\n\t\t\t\td['gap'] = '--'\n\t\t\telse:\n\t\t\t\td['gap'] = exchange.alarmAnalysis.fmt.dur(gap * 1000.0)\n\t\t\tif focus and src == focus:\n\t\t\t\tfor k in ('label', 'area', 'count', 'per_hour', 'gap'):\n\t\t\t\t\td[k] = {'value': d[k], 'style': hi}\n\t\t\tpname = r['priority_name'] or ''\n\t\t\tif pname in pri:\n\t\t\t\td['priority'] = {'value': pname, 'style': {'classes': 'exchange/alarm-analysis/priority-soft/' + pname.lower()}}\n\t\t\telse:\n\t\t\t\td['priority'] = pname\n\t\t\tout.append(d)\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "expr-struct" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "source", + "header": { + "title": "source" + }, + "visible": false + }, + { + "field": "label", + "header": { + "title": "Source" + }, + "width": 3 + }, + { + "field": "area", + "header": { + "title": "Area" + }, + "width": 1.6 + }, + { + "field": "priority", + "header": { + "justify": "center", + "title": "Priority" + }, + "justify": "center", + "width": 1.4 + }, + { + "field": "count", + "header": { + "justify": "right", + "title": "Count" + }, + "justify": "right", + "render": "number", + "width": 1 + }, + { + "field": "per_hour", + "header": { + "justify": "right", + "title": "Per Hr" + }, + "justify": "right", + "width": 1.1 + }, + { + "field": "gap", + "header": { + "justify": "right", + "title": "Med Gap" + }, + "justify": "right", + "width": 1.2 + } + ], + "pager": { + "bottom": false + }, + "rows": { + "height": 34 + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "EmptyChatter" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn not value['chattering']\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "check_circle", + "message": "No chattering alarms in range" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "CardChatter" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblStandingTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Standing / Stale" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "LblStandingCaption" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "Alarms active for more than 24 h" + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onRowClick": { + "config": { + "script": "\ttry:\n\t\trow = event.value\n\t\tsrc = row['source']\n\t\tlbl = row['label']\n\texcept:\n\t\treturn\n\ttry:\n\t\tsrc = src['value']\n\texcept:\n\t\tpass\n\ttry:\n\t\tlbl = lbl['value']\n\texcept:\n\t\tpass\n\tif not src:\n\t\treturn\n\ttry:\n\t\tsystem.perspective.openPopup('exchange.alarmAnalysis.alarmDetail', 'Exchange/AlarmAnalysis/Popups/AlarmDetail', params = {'source': src, 'label': lbl, 'startMs': self.view.params.query.startMs, 'endMs': self.view.params.query.endMs}, showCloseIcon = True, draggable = True)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "TblStanding" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['standing']['rows'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "struct": { + "bundle": "{view.params.bundle}", + "focus": "{view.params.focus}" + }, + "waitOnAll": false + }, + "transforms": [ + { + "code": "\ttry:\n\t\trows = value['bundle']['standing']['rows'] or []\n\texcept:\n\t\trows = []\n\ttry:\n\t\tfocus = value['focus'] or ''\n\texcept:\n\t\tfocus = ''\n\thi = {'backgroundColor': 'rgba(59, 125, 216, 0.18)', 'fontWeight': 'bold'}\n\tpri = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tout = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tsrc = r['source'] or ''\n\t\t\td = {}\n\t\t\td['source'] = src\n\t\t\td['label'] = r['label'] or src\n\t\t\td['area'] = r['area'] or ''\n\t\t\td['age'] = exchange.alarmAnalysis.fmt.dur(r['age_ms'])\n\t\t\tif r['unacked']:\n\t\t\t\td['unacked'] = {'value': 'Yes', 'style': {'color': '#D64550', 'fontWeight': 'bold'}}\n\t\t\telse:\n\t\t\t\td['unacked'] = ''\n\t\t\tif focus and src == focus:\n\t\t\t\tfor k in ('label', 'area', 'age'):\n\t\t\t\t\td[k] = {'value': d[k], 'style': hi}\n\t\t\tpname = r['priority_name'] or ''\n\t\t\tif pname in pri:\n\t\t\t\td['priority'] = {'value': pname, 'style': {'classes': 'exchange/alarm-analysis/priority-soft/' + pname.lower()}}\n\t\t\telse:\n\t\t\t\td['priority'] = pname\n\t\t\tout.append(d)\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "expr-struct" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "source", + "header": { + "title": "source" + }, + "visible": false + }, + { + "field": "label", + "header": { + "title": "Source" + }, + "width": 3 + }, + { + "field": "area", + "header": { + "title": "Area" + }, + "width": 1.6 + }, + { + "field": "priority", + "header": { + "justify": "center", + "title": "Priority" + }, + "justify": "center", + "width": 1.4 + }, + { + "field": "age", + "header": { + "justify": "right", + "title": "Active For" + }, + "justify": "right", + "width": 1.3 + }, + { + "field": "unacked", + "header": { + "justify": "center", + "title": "Unacked" + }, + "justify": "center", + "width": 1.1 + } + ], + "pager": { + "bottom": false + }, + "rows": { + "height": 34 + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "EmptyStanding" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn not value['standing']['rows']\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "check_circle", + "message": "No standing alarms over 24 h" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "CardStanding" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "4px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblFleetingTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Fleeting" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "LblFleetingCaption" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ttot = value['fleeting']['total']\n\texcept:\n\t\ttot = None\n\tif not tot:\n\t\treturn 'Activations lasting under 10 s'\n\treturn '%s fleeting activations (under 10 s)' % exchange.alarmAnalysis.fmt.num(tot)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + } + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onRowClick": { + "config": { + "script": "\ttry:\n\t\trow = event.value\n\t\tsrc = row['source']\n\t\tlbl = row['label']\n\texcept:\n\t\treturn\n\ttry:\n\t\tsrc = src['value']\n\texcept:\n\t\tpass\n\ttry:\n\t\tlbl = lbl['value']\n\texcept:\n\t\tpass\n\tif not src:\n\t\treturn\n\ttry:\n\t\tsystem.perspective.openPopup('exchange.alarmAnalysis.alarmDetail', 'Exchange/AlarmAnalysis/Popups/AlarmDetail', params = {'source': src, 'label': lbl, 'startMs': self.view.params.query.startMs, 'endMs': self.view.params.query.endMs}, showCloseIcon = True, draggable = True)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "TblFleeting" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['fleeting']['sources'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "struct": { + "bundle": "{view.params.bundle}", + "focus": "{view.params.focus}" + }, + "waitOnAll": false + }, + "transforms": [ + { + "code": "\ttry:\n\t\trows = value['bundle']['fleeting']['sources'] or []\n\texcept:\n\t\trows = []\n\ttry:\n\t\tfocus = value['focus'] or ''\n\texcept:\n\t\tfocus = ''\n\thi = {'backgroundColor': 'rgba(59, 125, 216, 0.18)', 'fontWeight': 'bold'}\n\tpri = ['Diagnostic', 'Low', 'Medium', 'High', 'Critical']\n\tout = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tsrc = r['source'] or ''\n\t\t\td = {}\n\t\t\td['source'] = src\n\t\t\td['label'] = r['label'] or src\n\t\t\td['area'] = r['area'] or ''\n\t\t\td['count'] = r['count'] or 0\n\t\t\tmed = r['median_s']\n\t\t\tif med is None:\n\t\t\t\td['median'] = '--'\n\t\t\telse:\n\t\t\t\td['median'] = exchange.alarmAnalysis.fmt.dur(med * 1000.0)\n\t\t\tif focus and src == focus:\n\t\t\t\tfor k in ('label', 'area', 'count', 'median'):\n\t\t\t\t\td[k] = {'value': d[k], 'style': hi}\n\t\t\tpname = r['priority_name'] or ''\n\t\t\tif pname in pri:\n\t\t\t\td['priority'] = {'value': pname, 'style': {'classes': 'exchange/alarm-analysis/priority-soft/' + pname.lower()}}\n\t\t\telse:\n\t\t\t\td['priority'] = pname\n\t\t\tout.append(d)\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "expr-struct" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "source", + "header": { + "title": "source" + }, + "visible": false + }, + { + "field": "label", + "header": { + "title": "Source" + }, + "width": 3 + }, + { + "field": "area", + "header": { + "title": "Area" + }, + "width": 1.6 + }, + { + "field": "priority", + "header": { + "justify": "center", + "title": "Priority" + }, + "justify": "center", + "width": 1.4 + }, + { + "field": "count", + "header": { + "justify": "right", + "title": "Count" + }, + "justify": "right", + "render": "number", + "width": 1 + }, + { + "field": "median", + "header": { + "justify": "right", + "title": "Med Dur" + }, + "justify": "right", + "width": 1.2 + } + ], + "pager": { + "bottom": false + }, + "rows": { + "height": 34 + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "EmptyFleeting" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn not value['fleeting']['sources']\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "check_circle", + "message": "No fleeting activations in range" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "CardFleeting" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "4px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "RowTables" + }, + "position": { + "basis": "280px", + "grow": 1 + }, + "props": { + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "Content" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value) and int(value['meta']['event_count'] or 0) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Journal/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Journal/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Journal/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Journal/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Journal/view.json new file mode 100644 index 0000000..39c5fe2 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Journal/view.json @@ -0,0 +1,484 @@ +{ + "custom": { + "page": 0, + "pageData": null + }, + "params": { + "bundle": null, + "query": null + }, + "propConfig": { + "custom.pageData": { + "binding": { + "config": { + "expression": "{view.params.query.refreshToken} + '|' + {view.params.query.startMs} + '|' + {view.params.query.endMs} + '|' + {view.custom.page}" + }, + "transforms": [ + { + "code": "\tsafe = {'rows': [], 'total': 0, 'page': 0, 'page_size': 50, 'truncated': False, 'error': None}\n\ttry:\n\t\tq = self.view.params.query\n\t\tif not q.startMs or not q.endMs:\n\t\t\treturn safe\n\t\ttry:\n\t\t\tpage = max(0, int(self.view.custom.page or 0))\n\t\texcept:\n\t\t\tpage = 0\n\t\treturn exchange.alarmAnalysis.alarms.getJournalPage(q.startMs, q.endMs, {'priorities': list(q.priorities), 'areas': list(q.areas), 'states': list(q.states), 'search': q.search}, page, 50)\n\texcept:\n\t\timport sys\n\t\tsafe['error'] = '%s' % (sys.exc_info()[1],)\n\t\treturn safe", + "type": "script" + } + ], + "type": "expr" + } + }, + "params.bundle": { + "paramDirection": "input", + "persistent": false + }, + "params.query": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 800, + "width": 1600 + } + }, + "root": { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblTotal" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ttotal = int(value['total'] or 0)\n\texcept:\n\t\ttotal = 0\n\ttry:\n\t\ttxt = '%s events' % exchange.alarmAnalysis.fmt.num(total)\n\texcept:\n\t\ttxt = '%d events' % total\n\ttry:\n\t\tif value['truncated']:\n\t\t\ttxt = txt + ' (truncated)'\n\texcept:\n\t\tpass\n\treturn txt", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "textStyle": { + "fontWeight": "bold" + } + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "LblError" + }, + "position": { + "shrink": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn bool(value['error'])\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.text": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn value['error'] or ''\n\texcept:\n\t\treturn ''", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted", + "color": "#D64550" + } + }, + "type": "ia.display.label" + }, + { + "children": [], + "meta": { + "name": "Spacer" + }, + "position": { + "grow": 1 + }, + "props": {}, + "type": "ia.container.flex" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\ttry:\n\t\tp = int(self.view.custom.page or 0)\n\texcept:\n\t\tp = 0\n\tif p > 0:\n\t\tself.view.custom.page = p - 1" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "BtnPrev" + }, + "position": { + "basis": "80px", + "shrink": 0 + }, + "propConfig": { + "props.enabled": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn int(value['page'] or 0) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "primary": false, + "text": "Prev" + }, + "type": "ia.input.button" + }, + { + "meta": { + "name": "LblPage" + }, + "position": { + "shrink": 0 + }, + "propConfig": { + "props.text": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ttotal = int(value['total'] or 0)\n\t\tsize = int(value['page_size'] or 50)\n\t\tpage = int(value['page'] or 0)\n\texcept:\n\t\ttotal = 0\n\t\tsize = 50\n\t\tpage = 0\n\tif size < 1:\n\t\tsize = 50\n\tpages = (total + size - 1) // size\n\tif pages < 1:\n\t\tpages = 1\n\ttry:\n\t\treturn 'Page %s of %s' % (exchange.alarmAnalysis.fmt.num(page + 1), exchange.alarmAnalysis.fmt.num(pages))\n\texcept:\n\t\treturn 'Page %d of %d' % (page + 1, pages)", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "textStyle": { + "textAlign": "center" + } + }, + "type": "ia.display.label" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\ttry:\n\t\tp = int(self.view.custom.page or 0)\n\texcept:\n\t\tp = 0\n\ttry:\n\t\td = self.view.custom.pageData\n\t\ttotal = int(d['total'] or 0)\n\t\tsize = int(d['page_size'] or 50)\n\texcept:\n\t\ttotal = 0\n\t\tsize = 50\n\tif size < 1:\n\t\tsize = 50\n\tpages = (total + size - 1) // size\n\tif pages < 1:\n\t\tpages = 1\n\tif p < pages - 1:\n\t\tself.view.custom.page = p + 1" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "BtnNext" + }, + "position": { + "basis": "80px", + "shrink": 0 + }, + "propConfig": { + "props.enabled": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\ttotal = int(value['total'] or 0)\n\t\tsize = int(value['page_size'] or 50)\n\t\tpage = int(value['page'] or 0)\n\texcept:\n\t\treturn False\n\tif size < 1:\n\t\tsize = 50\n\treturn (page + 1) * size < total", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "primary": false, + "text": "Next" + }, + "type": "ia.input.button" + }, + { + "events": { + "component": { + "onActionPerformed": { + "config": { + "script": "\ttry:\n\t\tq = self.view.params.query\n\t\tcsv = exchange.alarmAnalysis.alarms.journalCsv(q.startMs, q.endMs, {'priorities': list(q.priorities), 'areas': list(q.areas), 'states': list(q.states), 'search': q.search})\n\t\tsystem.perspective.download('alarm_journal.csv', csv)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "BtnExport" + }, + "position": { + "basis": "110px", + "shrink": 0 + }, + "props": { + "primary": true, + "text": "Export CSV" + }, + "type": "ia.input.button" + } + ], + "meta": { + "name": "Toolbar" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "classes": "exchange/alarm-analysis/toolbar", + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "events": { + "component": { + "onRowClick": { + "config": { + "script": "\trow = event.value\n\tevt = {}\n\ttry:\n\t\tfor k in row:\n\t\t\tv = row[k]\n\t\t\ttry:\n\t\t\t\tevt[k] = v['value']\n\t\t\texcept:\n\t\t\t\tevt[k] = v\n\texcept:\n\t\tevt = {}\n\ttry:\n\t\tif evt:\n\t\t\tsystem.perspective.openPopup('exchange.alarmAnalysis.eventDetail', 'Exchange/AlarmAnalysis/Popups/EventDetail', params = {'event': evt}, showCloseIcon = True, draggable = True)\n\texcept:\n\t\tpass" + }, + "scope": "G", + "type": "script" + } + } + }, + "meta": { + "name": "TblJournal" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['rows'] or []) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.data": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\tstate_cls = {'active': 'exchange/alarm-analysis/state/active', 'ack': 'exchange/alarm-analysis/state/acked', 'clear': 'exchange/alarm-analysis/state/cleared'}\n\tprio_cls = {'Diagnostic': 'exchange/alarm-analysis/priority-soft/diagnostic', 'Low': 'exchange/alarm-analysis/priority-soft/low', 'Medium': 'exchange/alarm-analysis/priority-soft/medium', 'High': 'exchange/alarm-analysis/priority-soft/high', 'Critical': 'exchange/alarm-analysis/priority-soft/critical'}\n\ttry:\n\t\trows = value['rows'] or []\n\texcept:\n\t\trows = []\n\tout = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tsc = state_cls.get(r['state'], '')\n\t\t\tpc = prio_cls.get(r['priority_name'], '')\n\t\t\tout.append({'time_label': r['time_label'] or '', 'state_label': {'value': r['state_label'] or '', 'style': {'classes': sc}}, 'priority_name': {'value': r['priority_name'] or '', 'style': {'classes': pc}}, 'label': r['label'] or '', 'area': r['area'] or '', 'ack_user': r['ack_user'] or '', 'id': r['id'] or '', 'time_ms': r['time_ms'] or 0, 'source': r['source'] or '', 'state': r['state'] or '', 'priority': r['priority']})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "cells": { + "style": { + "padding": "5px" + } + }, + "columns": [ + { + "field": "time_label", + "header": { + "title": "Time" + }, + "sortable": false, + "strictWidth": true, + "width": 150 + }, + { + "field": "state_label", + "header": { + "title": "State" + }, + "justify": "center", + "sortable": false, + "strictWidth": true, + "width": 110 + }, + { + "field": "priority_name", + "header": { + "title": "Priority" + }, + "justify": "center", + "sortable": false, + "strictWidth": true, + "width": 110 + }, + { + "field": "label", + "header": { + "title": "Alarm" + }, + "sortable": false + }, + { + "field": "area", + "header": { + "title": "Area" + }, + "sortable": false, + "strictWidth": true, + "width": 160 + }, + { + "field": "ack_user", + "header": { + "title": "Ack By" + }, + "sortable": false, + "strictWidth": true, + "width": 120 + }, + { + "field": "id", + "visible": false + }, + { + "field": "time_ms", + "visible": false + }, + { + "field": "source", + "visible": false + }, + { + "field": "state", + "visible": false + }, + { + "field": "priority", + "visible": false + } + ], + "pager": { + "bottom": false, + "top": false + } + }, + "type": "ia.display.table" + }, + { + "meta": { + "name": "Empty" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.custom.pageData" + }, + "transforms": [ + { + "code": "\ttry:\n\t\treturn len(value['rows'] or []) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "search", + "message": "No events match the current filters" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "CardTable" + }, + "position": { + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Overview/resource.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Overview/resource.json new file mode 100644 index 0000000..276dced --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Overview/resource.json @@ -0,0 +1,10 @@ +{ + "scope": "G", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "view.json" + ], + "attributes": {} +} diff --git a/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Overview/view.json b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Overview/view.json new file mode 100644 index 0000000..f6ad32f --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/com.inductiveautomation.perspective/views/Exchange/AlarmAnalysis/Tabs/Overview/view.json @@ -0,0 +1,1807 @@ +{ + "custom": {}, + "params": { + "bundle": null + }, + "propConfig": { + "params.bundle": { + "paramDirection": "input", + "persistent": false + } + }, + "props": { + "defaultSize": { + "height": 900, + "width": 1600 + } + }, + "root": { + "children": [ + { + "meta": { + "name": "EmptyWhole" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn not (int(value['meta']['event_count']) > 0)\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "inbox", + "message": "No alarm events in the selected range" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + }, + { + "children": [ + { + "meta": { + "name": "RepPriority" + }, + "position": { + "basis": "120px", + "shrink": 0 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\trows = []\n\ttry:\n\t\tfor r in (value['priority']['raw'] or []):\n\t\t\ttry:\n\t\t\t\trows.append({'name': r['name'], 'level': int(r['level']), 'count': int(r['count'])})\n\t\t\texcept:\n\t\t\t\tpass\n\texcept:\n\t\trows = []\n\trows.sort(key=lambda r: -r['level'])\n\ttotal = 0\n\tfor r in rows:\n\t\ttotal = total + r['count']\n\tout = []\n\tfor r in rows:\n\t\tif total > 0:\n\t\t\tp = 100.0 * r['count'] / total\n\t\telse:\n\t\t\tp = 0.0\n\t\tout.append({'priorityName': r['name'], 'count': r['count'], 'pct': p})\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "row", + "elementPosition": { + "basis": "0px", + "grow": 1, + "shrink": 1 + }, + "path": "Exchange/AlarmAnalysis/Components/PriorityCard", + "style": { + "gap": "10px", + "overflow": "visible" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + }, + { + "children": [ + { + "meta": { + "name": "KpiActiveNow" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn value['kpis']['active_now']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.num(value['kpis']['active_now']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Active Now" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiUnacked" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn value['kpis']['unacked_now']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.num(value['kpis']['unacked_now']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Unacked" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiShelved" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn value['kpis']['shelved_now']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.num(value['kpis']['shelved_now']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Shelved" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard" + }, + "type": "ia.display.view" + }, + { + "meta": { + "name": "KpiStanding" + }, + "position": { + "basis": "100px", + "grow": 1 + }, + "propConfig": { + "props.params.delta": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn value['kpis']['standing_count']\n\texcept:\n\t\treturn None", + "type": "script" + } + ], + "type": "property" + } + }, + "props.params.value": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn exchange.alarmAnalysis.fmt.num(value['kpis']['standing_count']['value'])\n\texcept:\n\t\treturn '--'", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "label": "Standing" + }, + "path": "Exchange/AlarmAnalysis/Components/KpiCard" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "RowStatus" + }, + "position": { + "basis": "104px", + "shrink": 0 + }, + "props": { + "alignItems": "stretch", + "style": { + "gap": "10px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblRateTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Alarm Rate Timeline" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "LblRateCaption" + }, + "position": { + "grow": 1 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/text/muted" + }, + "text": "flood bins in red \u00b7 dashed line = ISA target rate", + "textStyle": { + "textAlign": "right" + } + }, + "type": "ia.display.label" + } + ], + "meta": { + "name": "RateHeader" + }, + "position": { + "shrink": 0 + }, + "props": { + "alignItems": "center", + "style": { + "gap": "8px" + } + }, + "type": "ia.container.flex" + }, + { + "meta": { + "name": "ChartRate" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn len(value['rate']['bins']) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + }, + "props.dataSources.rate": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\trate = value['rate']\n\t\tbins = rate['bins'] or []\n\texcept:\n\t\treturn []\n\ttry:\n\t\ttarget = float(rate['target_per_bin'])\n\texcept:\n\t\ttarget = None\n\ttry:\n\t\tuse_day = float(value['meta']['window_hours']) > 24\n\texcept:\n\t\tuse_day = False\n\tout = []\n\tfor b in bins:\n\t\ttry:\n\t\t\tt0 = b['t0']\n\t\t\tc = int(b['count'])\n\t\texcept:\n\t\t\tcontinue\n\t\ttry:\n\t\t\tflood = bool(b['flood'])\n\t\texcept:\n\t\t\tflood = False\n\t\tif use_day:\n\t\t\tlbl = exchange.alarmAnalysis.fmt.dayClock(t0)\n\t\telse:\n\t\t\tlbl = exchange.alarmAnalysis.fmt.clock(t0)\n\t\trow = {'t': lbl, 'count_base': (0 if flood else c), 'count_flood': (c if flood else 0)}\n\t\tif target is not None:\n\t\t\trow['target'] = target\n\t\tout.append(row)\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "background": { + "color": "", + "gradient": { + "colors": [], + "direction": "linear", + "rotation": 0 + }, + "opacity": 1, + "render": "none" + }, + "cursor": { + "enabled": true, + "lineX": { + "enabled": true, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 0.4, + "width": 1 + } + }, + "lineY": { + "enabled": false, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 0.4, + "width": 1 + } + }, + "series": "" + }, + "dataSources": { + "rate": [] + }, + "enableTransitions": false, + "legend": { + "absolute": { + "x": 0, + "y": 0 + }, + "enabled": true, + "labels": { + "font": { + "color": "", + "size": 12, + "weight": 400 + }, + "text": "[bold]{name}[/]" + }, + "markers": { + "cornerRadius": { + "bottomLeft": 0, + "bottomRight": 0, + "topLeft": 0, + "topRight": 0 + }, + "enabled": true, + "height": 12, + "icon": { + "color": "", + "height": 12, + "horizontalCenter": "left", + "path": "", + "verticalCenter": "top", + "width": 12 + }, + "mirrorLookOfSeries": true, + "stroke": { + "color": "", + "opacity": 0, + "width": 1 + }, + "width": 12 + }, + "position": "bottom" + }, + "scrollBars": { + "horizontal": { + "enabled": false, + "series": "" + }, + "vertical": { + "enabled": false, + "series": "" + } + }, + "series": [ + { + "candlestick": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + } + }, + "high": { + "x": "", + "y": "" + }, + "low": { + "x": "", + "y": "" + }, + "open": { + "x": "", + "y": "" + } + }, + "column": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "#3B7DD8", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "height": null, + "stacked": true, + "stroke": { + "color": "", + "opacity": 0, + "width": 1 + }, + "width": 80 + }, + "open": { + "x": "", + "y": "" + } + }, + "data": { + "source": "rate", + "x": "t", + "y": "count_base" + }, + "defaultState": { + "visible": true + }, + "hiddenInLegend": false, + "label": { + "text": "Alarms" + }, + "line": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "name": "counts", + "render": "column", + "stepLine": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "pointerLength": 4, + "text": "{categoryX}: [bold]{valueY}[/] alarms" + }, + "visible": true, + "xAxis": "time", + "yAxis": "count", + "zIndex": 0 + }, + { + "candlestick": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + } + }, + "high": { + "x": "", + "y": "" + }, + "low": { + "x": "", + "y": "" + }, + "open": { + "x": "", + "y": "" + } + }, + "column": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "#D64550", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "height": null, + "stacked": true, + "stroke": { + "color": "", + "opacity": 0, + "width": 1 + }, + "width": 80 + }, + "open": { + "x": "", + "y": "" + } + }, + "data": { + "source": "rate", + "x": "t", + "y": "count_flood" + }, + "defaultState": { + "visible": true + }, + "hiddenInLegend": false, + "label": { + "text": "Flood bins" + }, + "line": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "name": "flood", + "render": "column", + "stepLine": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "pointerLength": 4, + "text": "{categoryX}: [bold]{valueY}[/] alarms (flood)" + }, + "visible": true, + "xAxis": "time", + "yAxis": "count", + "zIndex": 1 + }, + { + "candlestick": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "stacked": false, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + } + }, + "high": { + "x": "", + "y": "" + }, + "low": { + "x": "", + "y": "" + }, + "open": { + "x": "", + "y": "" + } + }, + "column": { + "appearance": { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": "", + "min": "" + }, + "height": null, + "stacked": false, + "stroke": { + "color": "", + "opacity": 0, + "width": 1 + }, + "width": 80 + }, + "open": { + "x": "", + "y": "" + } + }, + "data": { + "source": "rate", + "x": "t", + "y": "target" + }, + "defaultState": { + "visible": true + }, + "hiddenInLegend": false, + "label": { + "text": "ISA target" + }, + "line": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "#E8883A", + "dashArray": "6,4", + "opacity": 1, + "width": 2 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "name": "target", + "render": "line", + "stepLine": { + "appearance": { + "bullets": [ + { + "deriveFieldsFromData": { + "fill": { + "color": "", + "opacity": "" + }, + "rotation": "", + "stroke": { + "color": "", + "opacity": "", + "width": "" + } + }, + "enabled": false, + "fill": { + "color": "", + "opacity": 1 + }, + "heatRules": { + "dataField": "", + "enabled": false, + "max": 100, + "min": 2 + }, + "label": { + "hideOversized": true, + "position": { + "dx": 0, + "dy": 0 + }, + "text": "{value}", + "truncate": true + }, + "radius": 5, + "render": "circle", + "rotation": 0, + "stroke": { + "color": "", + "opacity": 1, + "width": 1 + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": true, + "getFillFromObject": true, + "pointerLength": 4, + "text": "{name}: [bold]{valueY}[/]" + } + } + ], + "connect": true, + "fill": { + "color": "", + "opacity": 0 + }, + "minDistance": 0.5, + "stroke": { + "color": "", + "dashArray": "", + "opacity": 1, + "width": 3 + }, + "tensionX": 1, + "tensionY": 1 + }, + "open": { + "x": "", + "y": "" + } + }, + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": false, + "pointerLength": 4, + "text": "" + }, + "visible": true, + "xAxis": "time", + "yAxis": "count", + "zIndex": 2 + } + ], + "style": { + "classes": "" + }, + "subtitle": { + "appearance": { + "color": "", + "font": { + "size": "", + "weight": 500 + }, + "padding": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0 + } + }, + "text": "" + }, + "title": { + "appearance": { + "color": "", + "font": { + "size": "", + "weight": 700 + }, + "padding": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0 + } + }, + "text": "" + }, + "xAxes": [ + { + "appearance": { + "font": { + "size": 11, + "weight": 500 + }, + "grid": { + "color": "", + "dashArray": "", + "minDistance": 140, + "opacity": 0, + "position": 0.5 + }, + "inside": false, + "labels": { + "color": "", + "horizontalCenter": "middle", + "opacity": 1, + "rotation": 0, + "verticalCenter": "top" + }, + "opposite": false + }, + "category": { + "break": { + "enabled": false, + "endCategory": "", + "size": 0.05, + "startCategory": "" + } + }, + "date": { + "baseInterval": { + "count": 1, + "enabled": false, + "skipEmptyPeriods": false, + "timeUnit": "hour" + }, + "break": { + "enabled": false, + "endDate": "", + "size": 0.05, + "startDate": "" + }, + "format": "M/d", + "inputFormat": "yyyy-MM-dd kk:mm:ss", + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "inversed": false, + "label": { + "color": "", + "enabled": false, + "text": "" + }, + "name": "time", + "render": "category", + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": false, + "pointerLength": 4, + "text": "" + }, + "value": { + "break": { + "enabled": false, + "endValue": 100, + "size": 0.05, + "startValue": 0 + }, + "format": "#,###.##", + "logarithmic": false, + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "visible": true + } + ], + "yAxes": [ + { + "appearance": { + "font": { + "size": 11, + "weight": 500 + }, + "grid": { + "color": "", + "dashArray": "3,3", + "minDistance": 30, + "opacity": 0.4, + "position": 0.5 + }, + "inside": false, + "labels": { + "color": "", + "horizontalCenter": "middle", + "opacity": 1, + "rotation": 0, + "verticalCenter": "middle" + }, + "opposite": false + }, + "category": { + "break": { + "enabled": false, + "endCategory": "", + "size": 0.05, + "startCategory": "" + } + }, + "date": { + "baseInterval": { + "count": 1, + "enabled": false, + "skipEmptyPeriods": false, + "timeUnit": "hour" + }, + "break": { + "enabled": false, + "endDate": "", + "size": 0.05, + "startDate": "" + }, + "format": "M/d", + "inputFormat": "yyyy-MM-dd kk:mm:ss", + "range": { + "max": "", + "min": "", + "useStrict": false + } + }, + "inversed": false, + "label": { + "color": "", + "enabled": true, + "text": "Alarms / 10 min" + }, + "name": "count", + "render": "value", + "tooltip": { + "background": { + "color": "", + "opacity": 1 + }, + "cornerRadius": 3, + "enabled": false, + "pointerLength": 4, + "text": "" + }, + "value": { + "break": { + "enabled": false, + "endValue": 100, + "size": 0.05, + "startValue": 0 + }, + "format": "#,###", + "logarithmic": false, + "range": { + "max": "", + "min": 0, + "useStrict": false + } + }, + "visible": true + } + ] + }, + "type": "ia.chart.xy" + }, + { + "meta": { + "name": "EmptyRate" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn len(value['rate']['bins']) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "bar_chart", + "message": "No rate data in the selected range" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "CardRate" + }, + "position": { + "basis": "0px", + "grow": 3 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "6px", + "minHeight": "260px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "children": [ + { + "meta": { + "name": "LblInsightsTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "What Changed" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "RepInsights" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\tout = []\n\ttry:\n\t\trows = value['insights'] or []\n\texcept:\n\t\trows = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tout.append({'severity': int(r['severity']), 'icon': r['icon'], 'text': r['text'], 'tab': r['tab']})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "elementPosition": { + "basis": "32px", + "grow": 0, + "shrink": 0 + }, + "path": "Exchange/AlarmAnalysis/Components/InsightRow", + "style": { + "gap": "4px", + "overflow": "auto" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + }, + { + "meta": { + "name": "EmptyInsights" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn len(value['insights']) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "insights", + "message": "No notable changes in this window" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "CardInsights" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + }, + { + "children": [ + { + "meta": { + "name": "LblTopTitle" + }, + "position": { + "shrink": 0 + }, + "props": { + "style": { + "classes": "exchange/alarm-analysis/card-title" + }, + "text": "Top 5 Sources" + }, + "type": "ia.display.label" + }, + { + "meta": { + "name": "RepSources" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "props.instances": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\tout = []\n\ttry:\n\t\trows = value['top_sources'] or []\n\texcept:\n\t\trows = []\n\tfor r in rows:\n\t\ttry:\n\t\t\tout.append({'rank': int(r['rank']), 'source': r['source'], 'label': r['label'], 'count': int(r['count']), 'pct': float(r['pct'])})\n\t\texcept:\n\t\t\tpass\n\treturn out", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "elementPosition": { + "basis": "34px", + "grow": 0, + "shrink": 0 + }, + "path": "Exchange/AlarmAnalysis/Components/SourceRow", + "style": { + "gap": "4px", + "overflow": "auto" + }, + "useDefaultViewHeight": false, + "useDefaultViewWidth": false + }, + "type": "ia.display.flex-repeater" + }, + { + "meta": { + "name": "EmptyTop" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn len(value['top_sources']) == 0\n\texcept:\n\t\treturn True", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "params": { + "icon": "leaderboard", + "message": "No alarm sources in this window" + }, + "path": "Exchange/AlarmAnalysis/Components/EmptyState" + }, + "type": "ia.display.view" + } + ], + "meta": { + "name": "CardTop" + }, + "position": { + "basis": "0px", + "grow": 1 + }, + "props": { + "direction": "column", + "style": { + "classes": "exchange/alarm-analysis/card", + "gap": "6px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "RowBottom" + }, + "position": { + "basis": "0px", + "grow": 2 + }, + "props": { + "style": { + "gap": "12px", + "minHeight": "200px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "Content" + }, + "position": { + "grow": 1 + }, + "propConfig": { + "position.display": { + "binding": { + "config": { + "path": "view.params.bundle" + }, + "transforms": [ + { + "code": "\ttry:\n\t\tif value['overview']:\n\t\t\tvalue = value['overview']\n\texcept:\n\t\tpass\n\ttry:\n\t\treturn int(value['meta']['event_count']) > 0\n\texcept:\n\t\treturn False", + "type": "script" + } + ], + "type": "property" + } + } + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } + ], + "meta": { + "name": "root" + }, + "props": { + "direction": "column", + "style": { + "gap": "12px" + } + }, + "type": "ia.container.flex" + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/ignition/global-props/data.bin b/ignition/gateway/projects/alarm-analysis/ignition/global-props/data.bin new file mode 100644 index 0000000..98e0fa0 Binary files /dev/null and b/ignition/gateway/projects/alarm-analysis/ignition/global-props/data.bin differ diff --git a/ignition/gateway/projects/alarm-analysis/ignition/global-props/resource.json b/ignition/gateway/projects/alarm-analysis/ignition/global-props/resource.json new file mode 100644 index 0000000..64f0add --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/ignition/global-props/resource.json @@ -0,0 +1,16 @@ +{ + "scope": "A", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "data.bin" + ], + "attributes": { + "lastModificationSignature": "e32343f8745d6acdf441eeb5996366a03b8b8f43d6c91e30251d2889f5311492", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T16:25:30Z" + } + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/alarms/code.py b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/alarms/code.py new file mode 100644 index 0000000..055d28f --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/alarms/code.py @@ -0,0 +1,478 @@ +from __future__ import division, print_function + +import sys + +from exchange.alarmAnalysis import calc, fmt + + +def _errText(): + try: + return "%s" % (sys.exc_info()[1],) + except: + return "unknown error" + + +def _nowMs(): + try: + return system.date.toMillis(system.date.now()) + except: + import time + return int(time.time() * 1000) + + +def _msOf(javaDate): + try: + if javaDate is None: + return None + return javaDate.getTime() + except: + return None + + +def _normEvent(evt): + try: + source = str(evt.getSource() or "") + state = None + try: + if evt.getAckData() is not None: + state = "ack" + elif evt.getClearedData() is not None: + state = "clear" + elif evt.getActiveData() is not None: + state = "active" + except: + state = None + if state is None: + s = "" + try: + s = str(evt.getState() or "") + except: + pass + if s.startswith("Cleared"): + state = "clear" + elif "Acknowledged" in s and not s.startswith("Active,"): + state = "ack" + else: + state = "active" + ts = None + try: + ts = _msOf(evt.get("eventTime")) + except: + ts = None + if ts is None: + return None + prioRaw = None + try: + p = evt.getPriority() + try: + prioRaw = int(p.ordinal()) + except: + prioRaw = str(p) + except: + prioRaw = None + lvl, name = calc.normalizePriority(prioRaw) + displayPath = "" + try: + displayPath = str(evt.getDisplayPath() or "") + except: + pass + isSystem = source.startswith("evt:") or ":/alm:" not in source + try: + if evt.get("isSystemEvent"): + isSystem = True + except: + pass + ackUser = None + try: + u = evt.get("ackUserName") + if u: + ackUser = str(u) + except: + pass + eventId = None + try: + eventId = str(evt.getId()) + except: + pass + return {"event_id": eventId, "source": source, "display_path": displayPath, + "priority": lvl, "priority_name": name, "state": state, "ts": ts, + "is_system": isSystem, "ack_user": ackUser} + except: + return None + + +def _fetchJournal(startMs, endMs, maxEvents, includeData=False): + """Returns (rows, dropped, truncated, error).""" + rows = [] + dropped = 0 + truncated = False + try: + kwargs = {"startDate": int(startMs), "endDate": int(endMs)} + if includeData: + # Required for ack attribution: without includeData the journal + # omits associated data, so evt.get("ackUserName") is always None. + kwargs["includeData"] = True + result = system.alarm.queryJournal(**kwargs) + for evt in result: + r = _normEvent(evt) + if r is None: + dropped += 1 + continue + rows.append(r) + if len(rows) >= maxEvents: + truncated = True + break + return rows, dropped, truncated, None + except: + return rows, dropped, truncated, _errText() + + +def _fetchStatus(): + out = [] + try: + result = system.alarm.queryStatus(state=["ActiveUnacked", "ActiveAcked"]) + for evt in result: + try: + source = str(evt.getSource() or "") + if source.startswith("evt:") or ":/alm:" not in source: + continue + activeMs = None + try: + data = evt.getActiveData() + if data is not None: + activeMs = _msOf(data.get("eventTime")) + except: + activeMs = None + if activeMs is None: + try: + activeMs = _msOf(evt.get("eventTime")) + except: + pass + prioRaw = None + try: + p = evt.getPriority() + try: + prioRaw = int(p.ordinal()) + except: + prioRaw = str(p) + except: + pass + lvl, name = calc.normalizePriority(prioRaw) + unacked = False + try: + unacked = "Unacknowledged" in str(evt.getState() or "") + except: + pass + displayPath = "" + try: + displayPath = str(evt.getDisplayPath() or "") + except: + pass + out.append({"source": source, "display_path": displayPath, + "priority": lvl, "priority_name": name, + "active_ms": activeMs, "unacked": unacked}) + except: + continue + except: + pass + return out + + +def _fetchShelved(): + try: + paths = system.alarm.getShelvedPaths() + return len(list(paths)) if paths is not None else 0 + except: + return None + + +def _cleanFilters(options): + f = {"priorities": [], "areas": [], "states": [], "search": ""} + if not options: + return f + try: + for k in ("priorities", "areas", "states"): + v = options.get(k) + if v: + f[k] = [x for x in list(v)] + f["priorities"] = [int(x) for x in f["priorities"]] + f["areas"] = [str(x) for x in f["areas"]] + f["states"] = [str(x) for x in f["states"]] + f["search"] = str(options.get("search") or "") + except: + pass + return f + + +# ---------------- Perspective entry points ---------------- + + +def getDashboardBundle(startMs, endMs, options=None): + """Build the dashboard bundle for a time window. + + Reads the alarm journal (plus the live alarm status for the active-now + figures) and hands the rows to calc.buildBundle. Never raises: on failure it + returns an empty bundle whose meta.error carries the message. + + Args: + startMs: Window start, epoch milliseconds. + endMs: Window end, epoch milliseconds. + options: Optional dict with "priorities", "areas", "states" and + "search" filter keys. + + Returns: + The dashboard bundle dict consumed by the Perspective views. + """ + try: + s = int(startMs) + e = int(endMs) + opts = calc.optsMerged(None) + filters = _cleanFilters(options) + span = max(e - s, 1) + rows, dropped, truncated, err = _fetchJournal( + s - span, e, int(opts["max_events"])) + activeNow = _fetchStatus() + shelved = _fetchShelved() + bundle = calc.buildBundle(rows, activeNow, shelved, s, e, _nowMs(), + {"filters": filters}) + bundle["meta"]["dropped_rows"] = dropped + bundle["meta"]["truncated"] = truncated + if err: + bundle["meta"]["error"] = err + bundle["insights"] = fmt.insights(bundle) + try: + if bundle.get("overview"): + bundle["overview"]["insights"] = fmt.insights(bundle["overview"]) + except: + pass + return bundle + except: + try: + bundle = calc.buildBundle([], [], None, int(startMs or 0), + int(endMs or 0), _nowMs(), None) + except: + bundle = calc.buildBundle([], [], None, 0, 0, 0, None) + bundle["meta"]["error"] = _errText() + bundle["insights"] = [] + return bundle + + +STATE_LABELS = {"active": "Active", "clear": "Cleared", "ack": "Acked"} + + +def _journalRows(startMs, endMs, filters, maxRows): + rows, dropped, truncated, err = _fetchJournal(startMs, endMs, maxRows, + includeData=True) + out = [] + prios = set(filters.get("priorities") or []) + areas = set(filters.get("areas") or []) + states = set(filters.get("states") or []) + search = (filters.get("search") or "").strip().lower() + for r in rows: + if r["is_system"]: + continue + parsed = calc.parseSource(r["source"], r["display_path"]) + if prios and r["priority"] not in prios: + continue + if areas and parsed["area"] not in areas: + continue + if states: + tag = {"active": "active", "clear": "cleared", "ack": "acked"}[r["state"]] + if tag not in states and not ("unacked" in states and r["state"] == "active"): + continue + if search and search not in (r["source"] + " " + parsed["label"]).lower(): + continue + out.append({"id": r["event_id"], "time_ms": r["ts"], + "time_label": fmt.dayClock(r["ts"]), + "source": r["source"], "label": parsed["label"], + "area": parsed["area"], "state": r["state"], + "state_label": STATE_LABELS.get(r["state"], r["state"]), + "priority": r["priority"], "priority_name": r["priority_name"], + "ack_user": r["ack_user"] or ""}) + out.sort(key=lambda r: -(r["time_ms"] or 0)) + return out, truncated, err + + +def getJournalPage(startMs, endMs, filters=None, page=0, pageSize=50): + """Return one page of filtered journal rows for the Journal tab. + + Args: + startMs: Window start, epoch milliseconds. + endMs: Window end, epoch milliseconds. + filters: Optional dict with "priorities", "areas", "states" and + "search" keys. + page: Zero-based page index. + pageSize: Rows per page. + + Returns: + Dict with "rows", "total", "page", "page_size", "truncated" and + "error". + """ + try: + f = _cleanFilters(filters) + rows, truncated, err = _journalRows(int(startMs), int(endMs), f, + int(calc.DEFAULTS["max_events"])) + page = max(0, int(page or 0)) + pageSize = max(1, int(pageSize or 50)) + start = page * pageSize + return {"rows": rows[start:start + pageSize], "total": len(rows), + "page": page, "page_size": pageSize, "truncated": truncated, + "error": err} + except: + return {"rows": [], "total": 0, "page": 0, "page_size": int(pageSize or 50), + "truncated": False, "error": _errText()} + + +def journalCsv(startMs, endMs, filters=None, maxRows=10000): + """Render the filtered journal rows as CSV text for download. + + Args: + startMs: Window start, epoch milliseconds. + endMs: Window end, epoch milliseconds. + filters: Optional dict with "priorities", "areas", "states" and + "search" keys. + maxRows: Hard cap on the number of journal rows read. + + Returns: + The CSV document as a string; a single "error,..." row on failure. + """ + try: + f = _cleanFilters(filters) + rows, truncated, err = _journalRows(int(startMs), int(endMs), f, int(maxRows)) + headers = ["time", "state", "priority", "label", "area", "source", "ack_user"] + data = [[r["time_label"], r["state_label"], r["priority_name"], r["label"], + r["area"], r["source"], r["ack_user"]] for r in rows] + ds = system.dataset.toDataSet(headers, data) + return system.dataset.toCSV(ds) + except: + return "error,%s" % _errText().replace(",", ";") + + +def getSourceDetail(source, startMs, endMs): + """Build the drill-down payload for one alarm source. + + Args: + source: The alarm source path to inspect. + startMs: Window start, epoch milliseconds. + endMs: Window end, epoch milliseconds. + + Returns: + Dict with "label", "area", recent "events", a "daily" count series, + summary "stats" and "error". + """ + try: + s = int(startMs) + e = int(endMs) + src = str(source or "") + rows, dropped, truncated, err = _fetchJournal(s, e, int(calc.DEFAULTS["max_events"]), + includeData=True) + mine = [r for r in rows if r["source"] == src] + instances, _mode = calc.correlateInstances(mine) + parsed = calc.parseSource(src, mine[0]["display_path"] if mine else "") + dayBins = calc.rateBins(instances, s, e, {"bin_ms": 86400000, "max_bins": 400}) + daily = [{"t0": b["t0"], "count": b["count"]} for b in dayBins["bins"]] + ttas = [i["tta_ms"] for i in instances if i.get("tta_ms") is not None] + durs = [i["duration_ms"] for i in instances if i.get("duration_ms") is not None] + fleet = len([i for i in instances + if i.get("duration_ms") is not None + and i["duration_ms"] < calc.DEFAULTS["fleeting_s"] * 1000]) + ackUsers = {} + for r in mine: + if r["state"] == "ack" and r.get("ack_user"): + ackUsers[r["ack_user"]] = ackUsers.get(r["ack_user"], 0) + 1 + topAck = [{"user": u, "count": c} for u, c in + sorted(ackUsers.items(), key=lambda kv: -kv[1])[:5]] + events = [] + for r in sorted(mine, key=lambda r: -r["ts"])[:100]: + events.append({"id": r["event_id"], "time_ms": r["ts"], + "time_label": fmt.dayClock(r["ts"]), + "state_label": STATE_LABELS.get(r["state"], r["state"]), + "priority_name": r["priority_name"], + "ack_user": r["ack_user"] or ""}) + return {"label": parsed["label"], "area": parsed["area"], "events": events, + "daily": daily, + "stats": {"count": len([i for i in instances if i["active_ms"] is not None]), + "avg_tta_ms": (sum(ttas) / len(ttas)) if ttas else None, + "avg_active_ms": (sum(durs) / len(durs)) if durs else None, + "fleeting_count": fleet, "top_ack_users": topAck}, + "error": err} + except: + return {"label": str(source or ""), "area": "", "events": [], "daily": [], + "stats": {"count": 0, "avg_tta_ms": None, "avg_active_ms": None, + "fleeting_count": 0, "top_ack_users": []}, + "error": _errText()} + + +def getEventData(eventId, aroundMs, source): + """Read the associated data properties of one journal event. + + The journal is searched one hour either side of the supplied timestamp, + because the event id alone is not queryable. + + Args: + eventId: The journal event id. + aroundMs: A timestamp close to the event, epoch milliseconds. + source: The alarm source path (informational). + + Returns: + Dict with a "props" list of name/value pairs and "error". + """ + try: + eid = str(eventId or "") + mid = int(aroundMs) + props = [] + seen = {} + try: + result = system.alarm.queryJournal(startDate=mid - 3600000, + endDate=mid + 3600000, + includeData=True) + except: + result = system.alarm.queryJournal(startDate=mid - 3600000, + endDate=mid + 3600000) + for evt in result: + try: + if str(evt.getId()) != eid: + continue + try: + names = evt.getProperties() + except: + names = [] + for p in names or []: + try: + n = str(p) + v = evt.get(p) + if n not in seen: + seen[n] = True + props.append({"name": n, "value": str(v)}) + except: + continue + except: + continue + props.sort(key=lambda kv: kv["name"]) + return {"props": props, "error": None} + except: + return {"props": [], "error": _errText()} + + +def getShiftReportText(shiftHours=8, anchorHour=0, options=None): + """Render the plain-text handover report for the previous shift. + + Args: + shiftHours: Shift length in hours. + anchorHour: Local hour at which the first shift of the day starts. + options: Optional filter dict passed through to getDashboardBundle. + + Returns: + Dict with the report "text", the "start_ms"/"end_ms" of the shift and + its "label". + """ + try: + bounds = fmt.shiftBounds(_nowMs(), int(shiftHours), int(anchorHour)) + s, e = bounds["previous"] + bundle = getDashboardBundle(s, e, options) + report = fmt.shiftReport(bundle, bounds["label"]) + return {"text": report["text"], "start_ms": s, "end_ms": e, + "label": bounds["label"]} + except: + return {"text": "Shift report unavailable: %s" % _errText(), + "start_ms": 0, "end_ms": 0, "label": ""} diff --git a/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/alarms/resource.json b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/alarms/resource.json new file mode 100644 index 0000000..11225a4 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/alarms/resource.json @@ -0,0 +1,17 @@ +{ + "scope": "A", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "code.py" + ], + "attributes": { + "hintScope": 2, + "lastModificationSignature": "ea536367797dbf88597c914b522bb3a79c7fa959ae200f6b12fcbb135126cefa", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T21:23:55Z" + } + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/calc/code.py b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/calc/code.py new file mode 100644 index 0000000..c7c9ed4 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/calc/code.py @@ -0,0 +1,1059 @@ +from __future__ import division, print_function + +DEFAULTS = { + "bin_ms": 600000, "flood_per_10min": 10, "flood_end_per_10min": 5, + "target_per_hr": 6.0, + "chatter_per_hr": 10.0, "chatter_gap_s": 120, "chatter_min_count": 5, + "fleeting_s": 10, "fleeting_min_count": 3, + "standing_hours": 24, "trend_buckets": 24, + "max_events": 50000, "max_bins": 5000, + "delta_flat_pct": 5.0, "insights_max": 5, + "priority_target": {"low": 80.0, "medium": 15.0, "high": 5.0}, + "health_weights": {"rate": 0.30, "flood": 0.25, "chatter": 0.20, + "standing": 0.15, "priority": 0.10}, + "grade_cuts": [(90, "A"), (80, "B"), (70, "C"), (60, "D"), (0, "F")], + "list_cap": 50, "active_now_cap": 200, +} + +PRIORITY_NAMES = {0: "Diagnostic", 1: "Low", 2: "Medium", 3: "High", 4: "Critical"} + + +def optsMerged(opts): + """Merge caller overrides onto the module DEFAULTS. + + Args: + opts: Dict of option overrides, or None. + + Returns: + A new dict holding every DEFAULTS key with the overrides applied. + """ + out = dict(DEFAULTS) + if opts: + for k in opts: + out[k] = opts[k] + return out + + +def clamp(v, lo, hi): + """Clamp a number to an inclusive range. + + Args: + v: The value to clamp. + lo: Lower bound. + hi: Upper bound. + + Returns: + v limited to the closed interval [lo, hi]. + """ + return max(lo, min(hi, v)) + + +def _median(values): + vs = sorted(v for v in values if v is not None) + n = len(vs) + if n == 0: + return None + if n % 2: + return float(vs[n // 2]) + return (vs[n // 2 - 1] + vs[n // 2]) / 2.0 + + +def _mean(values): + vs = [v for v in values if v is not None] + if not vs: + return None + return sum(vs) / float(len(vs)) + + +def _safeDiv(a, b): + try: + if not b: + return None + return a / float(b) + except Exception: + return None + + +# ---------------- path parsing ---------------- + + +def parseSource(source, displayPath): + """Split an alarm source path into a display label and an area. + + Args: + source: The alarm source path, e.g. "prov:default:/tag:Area/Pump:/alm:High". + displayPath: The alarm's display path; preferred over the tag path when set. + + Returns: + Dict with "label" (last two path segments), "area" (the parent segment) + and "segments" (the full segment list). + """ + source = source or "" + displayPath = displayPath or "" + tagSegments = [] + alarmName = "" + if ":/tag:" in source: + tagPart = source.split(":/tag:", 1)[1] + tagPath = tagPart.split(":", 1)[0] + tagSegments = [s for s in tagPath.split("/") if s] + if ":/alm:" in source: + alarmName = source.split(":/alm:", 1)[1].split(":", 1)[0] + if displayPath: + segments = [s for s in displayPath.split("/") if s] + elif tagSegments: + segments = list(tagSegments) + if alarmName and (not segments or alarmName.lower() != segments[-1].lower()): + segments.append(alarmName) + else: + segments = [source] if source else ["unknown"] + if len(segments) >= 2: + label = "/".join(segments[-2:]) + else: + label = segments[0] + base = tagSegments if tagSegments else segments + if len(base) >= 2: + area = base[-2] + else: + area = base[0] if base else "unknown" + return {"label": label, "area": area, "segments": segments} + + +def normalizePriority(value): + """Normalise an alarm priority to an ordinal level and a name. + + Args: + value: A priority ordinal (0-4), a priority name, or None. + + Returns: + Tuple of (level, name). Level is -1 and name is the raw text when the + priority cannot be recognised. + """ + try: + iv = int(value) + if 0 <= iv <= 4: + return iv, PRIORITY_NAMES[iv] + except Exception: + pass + s = str(value).strip().lower() if value is not None else "" + for lvl in PRIORITY_NAMES: + if PRIORITY_NAMES[lvl].lower() in s and s: + return lvl, PRIORITY_NAMES[lvl] + return -1, (str(value) if value is not None else "Unknown") + + +# ---------------- correlation ---------------- + + +def _newInstance(row): + lvl, name = row.get("priority", -1), row.get("priority_name", "Unknown") + parsed = parseSource(row.get("source"), row.get("display_path")) + return {"event_id": row.get("event_id"), "source": row.get("source") or "", + "label": parsed["label"], "area": parsed["area"], + "priority": lvl, "priority_name": name, + "active_ms": None, "clear_ms": None, "ack_ms": None, + "duration_ms": None, "tta_ms": None, "open": True, + "ack_user": None, "n_events": 0} + + +def _finalize(inst): + a, c, k = inst["active_ms"], inst["clear_ms"], inst["ack_ms"] + inst["duration_ms"] = (c - a) if (a is not None and c is not None and c >= a) else None + inst["tta_ms"] = (k - a) if (a is not None and k is not None and k >= a) else None + inst["open"] = c is None + return inst + + +def correlateInstances(rows): + """Fold raw journal rows into one record per alarm instance. + + Rows are grouped by journal event id when most rows carry one, otherwise by + source using an active/clear state machine. + + Args: + rows: Normalised journal rows (see alarms._normEvent). + + Returns: + Tuple of (instances, mode) where mode is "uuid" or "source-sequence". + Each instance carries active_ms/clear_ms/ack_ms plus derived duration_ms + and tta_ms. + """ + rows = [r for r in (rows or []) if r and not r.get("is_system") and r.get("ts") is not None] + rows.sort(key=lambda r: r["ts"]) + if not rows: + return [], "uuid" + withId = [r for r in rows if r.get("event_id")] + mode = "uuid" if len(withId) >= 0.9 * len(rows) else "source-sequence" + instances = [] + if mode == "uuid": + groups, order = {}, [] + for i, r in enumerate(rows): + k = r.get("event_id") or ("_noid_%d" % i) + if k not in groups: + groups[k] = [] + order.append(k) + groups[k].append(r) + for k in order: + grp = groups[k] + ident = None + for r in grp: + if r["state"] == "active": + ident = r + break + inst = _newInstance(ident or grp[0]) + inst["n_events"] = len(grp) + acts = [r["ts"] for r in grp if r["state"] == "active"] + clears = [r["ts"] for r in grp if r["state"] == "clear"] + acks = [r["ts"] for r in grp if r["state"] == "ack"] + inst["active_ms"] = min(acts) if acts else None + inst["clear_ms"] = min(clears) if clears else None + inst["ack_ms"] = min(acks) if acks else None + for r in grp: + if r.get("ack_user"): + inst["ack_user"] = r["ack_user"] + break + instances.append(_finalize(inst)) + else: + openBySrc = {} + for r in rows: + src = r.get("source") or "" + st = r.get("state") + if st == "active": + if src in openBySrc: + instances.append(_finalize(openBySrc.pop(src))) + inst = _newInstance(r) + inst["active_ms"] = r["ts"] + inst["n_events"] = 1 + openBySrc[src] = inst + else: + inst = openBySrc.get(src) + if inst is None: + inst = _newInstance(r) + openBySrc[src] = inst + inst["n_events"] = inst.get("n_events", 0) + 1 + if st == "clear" and inst["clear_ms"] is None: + inst["clear_ms"] = r["ts"] + elif st == "ack" and inst["ack_ms"] is None: + inst["ack_ms"] = r["ts"] + if r.get("ack_user"): + inst["ack_user"] = r["ack_user"] + for src in openBySrc: + instances.append(_finalize(openBySrc[src])) + instances.sort(key=lambda i: i["active_ms"] if i["active_ms"] is not None else 0) + return instances, mode + + +def partitionInstances(instances, startMs, endMs): + """Split instances into the current window, the prior window and orphans. + + The prior window is the same length as the current one, immediately before it. + + Args: + instances: Alarm instances from correlateInstances. + startMs: Window start, epoch milliseconds. + endMs: Window end, epoch milliseconds. + + Returns: + Tuple of (current, prior, orphans) instance lists. + """ + priorStart = startMs - (endMs - startMs) + current, prior, orphans = [], [], [] + for inst in instances or []: + a = inst.get("active_ms") + if a is None: + orphans.append(inst) + elif startMs <= a <= endMs: + current.append(inst) + elif priorStart <= a < startMs: + prior.append(inst) + else: + orphans.append(inst) + return current, prior, orphans + + +def applyFilters(instances, filters): + """Filter alarm instances by priority, area, state and search text. + + Args: + instances: Alarm instances from correlateInstances. + filters: Dict with "priorities", "areas", "states" and "search" keys. + + Returns: + The matching instances, or the input unchanged when no filter is set. + """ + if not filters: + return instances + prios = set(filters.get("priorities") or []) + areas = set(filters.get("areas") or []) + states = set(filters.get("states") or []) + search = (filters.get("search") or "").strip().lower() + if not prios and not areas and not states and not search: + return instances + out = [] + for inst in instances: + if prios and inst["priority"] not in prios: + continue + if areas and inst["area"] not in areas: + continue + if states: + tags = set() + if inst["open"]: + tags.add("active") + if inst["clear_ms"] is not None: + tags.add("cleared") + if inst["ack_ms"] is not None: + tags.add("acked") + else: + tags.add("unacked") + if not (states & tags): + continue + if search: + hay = (inst["source"] + " " + inst["label"]).lower() + if search not in hay: + continue + out.append(inst) + return out + + +def applyStatusFilters(statusRows, filters): + """Filter live queryStatus rows with the same semantics as applyFilters. + + Args: + statusRows: Rows from alarms._fetchStatus, keyed source, + display_path, priority and unacked. + filters: Dict with "priorities", "areas", "states" and "search" + keys. + + Returns: + The matching rows, or the input unchanged when no filter is set. + """ + if not filters: + return statusRows + prios = set(filters.get("priorities") or []) + areas = set(filters.get("areas") or []) + states = set(filters.get("states") or []) + search = (filters.get("search") or "").strip().lower() + if not prios and not areas and not states and not search: + return statusRows + out = [] + for s in statusRows or []: + if prios and s.get("priority") not in prios: + continue + parsed = parseSource(s.get("source"), s.get("display_path")) + if areas and parsed["area"] not in areas: + continue + if states: + tags = set(["active"]) + tags.add("unacked" if s.get("unacked") else "acked") + if not (states & tags): + continue + if search: + hay = ((s.get("source") or "") + " " + parsed["label"]).lower() + if search not in hay: + continue + out.append(s) + return out + + +# ---------------- rate / floods ---------------- + + +def rateBins(instances, startMs, endMs, opts=None): + """Bin alarm activations into fixed time buckets for the rate chart. + + Bin width doubles until the bucket count fits within the "max_bins" option. + + Args: + instances: Alarm instances from correlateInstances. + startMs: Window start, epoch milliseconds. + endMs: Window end, epoch milliseconds. + opts: Option overrides, or None for DEFAULTS. + + Returns: + Dict with "bins", "bin_ms", "target_per_bin", "flood_threshold" and + "max_count". + """ + o = optsMerged(opts) + binMs = int(o["bin_ms"]) + empty = {"bins": [], "bin_ms": binMs, + "target_per_bin": o["target_per_hr"] * binMs / 3600000.0, + "flood_threshold": 1, "max_count": 0} + if endMs is None or startMs is None or endMs <= startMs: + return empty + while (endMs - startMs) // binMs + 1 > o["max_bins"]: + binMs *= 2 + origin = (startMs // binMs) * binMs + n = int((endMs - origin) // binMs) + 1 + counts = {} + for inst in instances or []: + a = inst.get("active_ms") + if a is None or a < origin or a > endMs: + continue + idx = int((a - origin) // binMs) + counts[idx] = counts.get(idx, 0) + 1 + floodThreshold = max(1, int(round(o["flood_per_10min"] * binMs / 600000.0))) + bins = [] + maxCount = 0 + for i in range(n): + c = counts.get(i, 0) + maxCount = max(maxCount, c) + bins.append({"t0": origin + i * binMs, "t1": origin + (i + 1) * binMs, + "count": c, "flood": c > floodThreshold}) + return {"bins": bins, "bin_ms": binMs, + "target_per_bin": o["target_per_hr"] * binMs / 3600000.0, + "flood_threshold": floodThreshold, "max_count": maxCount} + + +def floodEpisodes(binsResult, instances, startMs, endMs, opts=None): + """Find alarm flood episodes and the share of the window spent in flood. + + An episode opens when a bin exceeds the flood threshold and closes when a bin + falls below the hysteresis threshold. + + Args: + binsResult: The dict returned by rateBins. + instances: Alarm instances from correlateInstances. + startMs: Window start, epoch milliseconds. + endMs: Window end, epoch milliseconds. + opts: Option overrides, or None for DEFAULTS. + + Returns: + Dict with "episodes" (each with duration, event count and top source) + and "pct_time_in_flood". + """ + o = optsMerged(opts) + bins = (binsResult or {}).get("bins") or [] + binMs = (binsResult or {}).get("bin_ms") or o["bin_ms"] + thr = (binsResult or {}).get("flood_threshold") or 1 + endThr = max(1, int(round(o["flood_end_per_10min"] * binMs / 600000.0))) + episodes = [] + cur = None + for b in bins: + if cur is None: + if b["count"] > thr: + cur = {"start_ms": b["t0"], "end_ms": b["t1"], "peak_bin_count": b["count"]} + else: + if b["count"] >= endThr: + cur["end_ms"] = b["t1"] + cur["peak_bin_count"] = max(cur["peak_bin_count"], b["count"]) + else: + episodes.append(cur) + cur = None + if cur is not None: + episodes.append(cur) + for ep in episodes: + ep["duration_ms"] = ep["end_ms"] - ep["start_ms"] + srcCounts = {} + areaCounts = {} + total = 0 + for inst in instances or []: + a = inst.get("active_ms") + if a is not None and ep["start_ms"] <= a < ep["end_ms"]: + total += 1 + key = (inst["source"], inst["label"]) + srcCounts[key] = srcCounts.get(key, 0) + 1 + areaCounts[inst["area"]] = areaCounts.get(inst["area"], 0) + 1 + ep["event_count"] = total + if srcCounts: + best = sorted(srcCounts.items(), key=lambda kv: (-kv[1], kv[0][1]))[0] + ep["top_source_label"] = best[0][1] + ep["top_source_count"] = best[1] + else: + ep["top_source_label"] = "" + ep["top_source_count"] = 0 + if areaCounts: + ep["top_area"] = sorted(areaCounts.items(), key=lambda kv: (-kv[1], kv[0]))[0][0] + else: + ep["top_area"] = "" + span = (endMs - startMs) if (endMs and startMs and endMs > startMs) else 0 + pct = 0.0 + if span: + pct = 100.0 * sum(ep["duration_ms"] for ep in episodes) / span + return {"episodes": episodes, "pct_time_in_flood": pct} + + +# ---------------- detections ---------------- + + +def chattering(instances, startMs, endMs, opts=None): + """Identify sources that re-trigger faster than the chatter threshold. + + Args: + instances: Alarm instances from correlateInstances. + startMs: Window start, epoch milliseconds. + endMs: Window end, epoch milliseconds. + opts: Option overrides, or None for DEFAULTS. + + Returns: + List of chattering sources with count, per-hour rate and median gap, + worst first and capped at the "list_cap" option. + """ + o = optsMerged(opts) + windowH = _safeDiv(endMs - startMs, 3600000.0) if (endMs and startMs) else None + if not windowH or windowH <= 0: + return [] + groups = {} + for inst in instances or []: + a = inst.get("active_ms") + if a is None: + continue + groups.setdefault(inst["source"], []).append(inst) + out = [] + for src in groups: + insts = groups[src] + count = len(insts) + if count < o["chatter_min_count"]: + continue + perHour = count / windowH + times = sorted(i["active_ms"] for i in insts) + gaps = [times[i + 1] - times[i] for i in range(len(times) - 1)] + medGap = _median(gaps) + if perHour > o["chatter_per_hr"] and medGap is not None and medGap <= o["chatter_gap_s"] * 1000: + ref = insts[0] + out.append({"source": src, "label": ref["label"], "area": ref["area"], + "priority_name": ref["priority_name"], "count": count, + "per_hour": perHour, "median_gap_s": medGap / 1000.0}) + out.sort(key=lambda r: (-r["per_hour"], r["label"])) + return out[:int(o["list_cap"])] + + +def fleeting(instances, opts=None): + """Identify alarms that clear again almost immediately. + + Args: + instances: Alarm instances from correlateInstances. + opts: Option overrides, or None for DEFAULTS. + + Returns: + Dict with "total" fleeting activations and a "sources" list. + """ + o = optsMerged(opts) + thresh = o["fleeting_s"] * 1000 + groups = {} + total = 0 + for inst in instances or []: + d = inst.get("duration_ms") + if d is not None and d < thresh: + total += 1 + groups.setdefault(inst["source"], []).append(inst) + sources = [] + for src in groups: + insts = groups[src] + if len(insts) < o["fleeting_min_count"]: + continue + ref = insts[0] + sources.append({"source": src, "label": ref["label"], "area": ref["area"], + "priority_name": ref["priority_name"], "count": len(insts), + "median_s": (_median([i["duration_ms"] for i in insts]) or 0) / 1000.0}) + sources.sort(key=lambda r: (-r["count"], r["label"])) + return {"total": total, "sources": sources[:int(o["list_cap"])]} + + +def standing(activeNow, openInstances, nowMs, opts=None): + """List alarms that have stayed active for longer than the threshold. + + Uses the live queryStatus rows when they are available and falls back to + still-open journal instances otherwise. + + Args: + activeNow: Rows from alarms._fetchStatus, or an empty list. + openInstances: Instances still open at the end of the window. + nowMs: Current time, epoch milliseconds. + opts: Option overrides, or None for DEFAULTS. + + Returns: + Dict with "mode" ("status" or "journal"), "count" over the threshold + and the "rows" themselves, oldest first. + """ + o = optsMerged(opts) + thresh = o["standing_hours"] * 3600000.0 + rows = [] + if activeNow: + mode = "status" + for s in activeNow: + a = s.get("active_ms") + age = (nowMs - a) if (a is not None and nowMs) else None + parsed = parseSource(s.get("source"), s.get("display_path")) + rows.append({"source": s.get("source") or "", "label": parsed["label"], + "area": parsed["area"], + "priority_name": s.get("priority_name", "Unknown"), + "active_ms": a, "age_ms": age, + "age_h": (age / 3600000.0) if age is not None else None, + "unacked": bool(s.get("unacked"))}) + else: + mode = "journal" + for inst in openInstances or []: + a = inst.get("active_ms") + age = (nowMs - a) if (a is not None and nowMs) else None + rows.append({"source": inst["source"], "label": inst["label"], + "area": inst["area"], "priority_name": inst["priority_name"], + "active_ms": a, "age_ms": age, + "age_h": (age / 3600000.0) if age is not None else None, + "unacked": inst.get("ack_ms") is None}) + rows.sort(key=lambda r: -(r["age_ms"] or -1)) + count = len([r for r in rows if r["age_ms"] is not None and r["age_ms"] > thresh]) + return {"mode": mode, "count": count, "rows": rows[:int(o["list_cap"])]} + + +# ---------------- MTTA / MTTR ---------------- + + +def mttaMttr(instances, startMs, endMs, opts=None): + """Compute mean/median time-to-acknowledge and time-to-resolve. + + Args: + instances: Alarm instances from correlateInstances. + startMs: Window start, epoch milliseconds. + endMs: Window end, epoch milliseconds. + opts: Option overrides, or None for DEFAULTS. + + Returns: + Dict with "mtta", "mttr" (each mean/median/count) and a "trend" list + of per-bucket medians. + """ + o = optsMerged(opts) + ttas = [i["tta_ms"] for i in instances or [] if i.get("tta_ms") is not None and i["tta_ms"] >= 0] + durs = [i["duration_ms"] for i in instances or [] if i.get("duration_ms") is not None and i["duration_ms"] >= 0] + result = {"mtta": {"mean_ms": _mean(ttas), "median_ms": _median(ttas), "count": len(ttas)}, + "mttr": {"mean_ms": _mean(durs), "median_ms": _median(durs), "count": len(durs)}, + "trend": []} + if not endMs or not startMs or endMs <= startMs: + return result + span = endMs - startMs + binMs = int(o["bin_ms"]) + width = max(binMs, ((span // int(o["trend_buckets"])) // binMs + 1) * binMs) + origin = (startMs // width) * width + n = int((endMs - origin) // width) + 1 + buckets = {} + for inst in instances or []: + a = inst.get("active_ms") + if a is None: + continue + idx = int((a - origin) // width) + b = buckets.setdefault(idx, {"tta": [], "dur": []}) + if inst.get("tta_ms") is not None and inst["tta_ms"] >= 0: + b["tta"].append(inst["tta_ms"]) + if inst.get("duration_ms") is not None and inst["duration_ms"] >= 0: + b["dur"].append(inst["duration_ms"]) + for i in range(n): + b = buckets.get(i, {"tta": [], "dur": []}) + result["trend"].append({"t0": origin + i * width, + "mtta_ms": _median(b["tta"]), + "mttr_ms": _median(b["dur"]), + "ack_n": len(b["tta"]), "clear_n": len(b["dur"])}) + return result + + +# ---------------- pareto / heatmap / priority ---------------- + + +def pareto(instances, opts=None): + """Rank the top ten alarm sources by activation count. + + Args: + instances: Alarm instances from correlateInstances. + opts: Option overrides, or None for DEFAULTS. + + Returns: + Dict with "total_activations" and "rows", each row carrying rank, count, + share and cumulative share. + """ + counts = {} + refs = {} + total = 0 + for inst in instances or []: + if inst.get("active_ms") is None: + continue + total += 1 + src = inst["source"] + counts[src] = counts.get(src, 0) + 1 + if src not in refs: + refs[src] = inst + ordered = sorted(counts.items(), key=lambda kv: (-kv[1], refs[kv[0]]["label"]))[:10] + rows = [] + cum = 0 + for i, (src, c) in enumerate(ordered): + cum += c + ref = refs[src] + rows.append({"rank": i + 1, "source": src, "label": ref["label"], + "area": ref["area"], "priority_name": ref["priority_name"], + "count": c, "pct": 100.0 * c / total if total else 0.0, + "cum_pct": 100.0 * cum / total if total else 0.0}) + return {"total_activations": total, "rows": rows} + + +def topSources(paretoResult, n=5): + """Take the first n rows of a pareto result. + + Args: + paretoResult: The dict returned by pareto. + n: How many rows to return. + + Returns: + A list of at most n pareto rows. + """ + return list((paretoResult or {}).get("rows") or [])[:n] + + +def heatmap(instances, opts=None): + """Count activations into a 7 x 24 day-of-week by hour-of-day grid. + + Args: + instances: Alarm instances from correlateInstances. + opts: Option overrides, or None for DEFAULTS. + + Returns: + Dict with "rows" (7 lists of 24 counts), "row_labels", "max_count" and + "total". + """ + import time as _time + rows = [[0] * 24 for _ in range(7)] + total = 0 + mx = 0 + for inst in instances or []: + a = inst.get("active_ms") + if a is None: + continue + lt = _time.localtime(a / 1000.0) + rows[lt.tm_wday][lt.tm_hour] += 1 + total += 1 + if rows[lt.tm_wday][lt.tm_hour] > mx: + mx = rows[lt.tm_wday][lt.tm_hour] + return {"rows": rows, "row_labels": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], + "max_count": mx, "total": total} + + +def priorityDistribution(instances, opts=None): + """Compare the alarm priority mix against the ISA 80/15/5 target. + + Args: + instances: Alarm instances from correlateInstances. + opts: Option overrides, or None for DEFAULTS. + + Returns: + Dict with the "raw" per-priority counts, low/medium/high "buckets", + their "pct" shares, the "target" and "sum_abs_dev" from it. + """ + o = optsMerged(opts) + rawCounts = {} + buckets = {"low": 0, "medium": 0, "high": 0, "other": 0} + for inst in instances or []: + lvl = inst.get("priority", -1) + name = inst.get("priority_name", "Unknown") + key = (lvl, name) + rawCounts[key] = rawCounts.get(key, 0) + 1 + if lvl in (0, 1): + buckets["low"] += 1 + elif lvl == 2: + buckets["medium"] += 1 + elif lvl in (3, 4): + buckets["high"] += 1 + else: + buckets["other"] += 1 + raw = [{"name": k[1], "level": k[0], "count": rawCounts[k]} + for k in sorted(rawCounts.keys())] + bucketed = buckets["low"] + buckets["medium"] + buckets["high"] + pct = {"low": 0.0, "medium": 0.0, "high": 0.0} + sumAbsDev = None + target = o["priority_target"] + if bucketed: + for k in pct: + pct[k] = 100.0 * buckets[k] / bucketed + sumAbsDev = (abs(pct["low"] - target["low"]) + + abs(pct["medium"] - target["medium"]) + + abs(pct["high"] - target["high"])) + return {"raw": raw, "buckets": buckets, "pct": pct, + "target": dict(target), "sum_abs_dev": sumAbsDev} + + +# ---------------- health / deltas ---------------- + + +def _piecewise(x, points): + if x is None: + return None + pts = sorted(points) + if x <= pts[0][0]: + return float(pts[0][1]) + if x >= pts[-1][0]: + return float(pts[-1][1]) + for i in range(len(pts) - 1): + x0, y0 = pts[i] + x1, y1 = pts[i + 1] + if x0 <= x <= x1: + if x1 == x0: + return float(y1) + return y0 + (y1 - y0) * (x - x0) / float(x1 - x0) + return float(pts[-1][1]) + + +def healthScore(ratePerHr, pctFlood, chatterCount, standingCount, + sumAbsDev, hasActivity, opts=None): + """Score alarm-system health out of 100 and grade it A to F. + + Each sub-score is weighted by the "health_weights" option; sub-scores with no + data are dropped and the remaining weights are renormalised. + + Args: + ratePerHr: Activations per hour, or None. + pctFlood: Percentage of the window spent in flood, or None. + chatterCount: Number of chattering sources, or None. + standingCount: Number of standing alarms, or None. + sumAbsDev: Priority-distribution deviation from target, or None. + hasActivity: False when the window holds no activations at all. + opts: Option overrides, or None for DEFAULTS. + + Returns: + Dict with "grade", "score" and the per-dimension "subs" list. + """ + o = optsMerged(opts) + if not hasActivity: + rateS, floodS, chatterS = 100.0, 100.0, 100.0 + else: + rateS = _piecewise(ratePerHr, [(6, 100), (12, 60), (60, 0)]) + floodS = _piecewise(pctFlood, [(0, 100), (1, 80), (5, 0)]) + chatterS = clamp(100.0 - 25.0 * chatterCount, 0, 100) if chatterCount is not None else None + standingS = clamp(100.0 - 20.0 * standingCount, 0, 100) if standingCount is not None else None + priorityS = clamp(100.0 - sumAbsDev, 0, 100) if sumAbsDev is not None else None + weights = o["health_weights"] + subs = [ + {"key": "rate", "label": "Alarm Rate", "score": rateS, "weight": weights["rate"], + "detail": ("%.1f alarms/hr vs %.0f/hr ISA target" % (ratePerHr, o["target_per_hr"])) + if ratePerHr is not None else "no data"}, + {"key": "flood", "label": "Time in Flood", "score": floodS, "weight": weights["flood"], + "detail": ("%.1f%% of window in flood (target <1%%)" % pctFlood) + if pctFlood is not None else "no data"}, + {"key": "chatter", "label": "Chattering Alarms", "score": chatterS, "weight": weights["chatter"], + "detail": ("%d chattering alarm(s) (target 0)" % chatterCount) + if chatterCount is not None else "no data"}, + {"key": "standing", "label": "Standing Alarms", "score": standingS, "weight": weights["standing"], + "detail": ("%d standing alarm(s) >24h (target 0)" % standingCount) + if standingCount is not None else "no data"}, + {"key": "priority", "label": "Priority Distribution", "score": priorityS, "weight": weights["priority"], + "detail": ("%.0f pts deviation from ISA 80/15/5" % sumAbsDev) + if sumAbsDev is not None else "no data"}, + ] + num = 0.0 + den = 0.0 + for s in subs: + if s["score"] is not None: + num += s["score"] * s["weight"] + den += s["weight"] + score = (num / den) if den else None + grade = None + if score is not None: + for cut, g in o["grade_cuts"]: + if score >= cut: + grade = g + break + if grade is None: + grade = "F" + return {"grade": grade, "score": score, "subs": subs} + + +def delta(current, prior, higherIsWorse=True, flatPct=5.0): + """Compare a KPI against its prior-period value. + + Args: + current: The current value, or None. + prior: The prior-period value, or None when there is no comparison. + higherIsWorse: True when an increase is a regression. + flatPct: Percentage change below which the delta counts as flat. + + Returns: + Dict with "value", "prior", "delta_pct", "dir" (new/up/down/flat/none) + and "good". + """ + d = {"value": current, "prior": prior, "delta_pct": None, "dir": "none", "good": None} + cur0 = current or 0 + pri0 = prior or 0 + if prior is None: + d["dir"] = "new" if cur0 > 0 else "none" + return d + if pri0 == 0: + d["dir"] = "new" if cur0 > 0 else "none" + return d + if current is None: + return d + dp = 100.0 * (current - prior) / float(prior) + d["delta_pct"] = dp + if abs(dp) < flatPct: + d["dir"] = "flat" + return d + d["dir"] = "up" if dp > 0 else "down" + d["good"] = (d["dir"] == "down") == bool(higherIsWorse) + return d + + +# ---------------- the bundle ---------------- + + +def buildBundle(rows, activeNow, shelvedCount, startMs, endMs, nowMs, opts=None): + """Build the whole dashboard payload from raw journal and status rows. + + When a filter is active the top-level sections describe the filtered data and + an extra unfiltered copy is placed under "overview", so the Overview tab stays + a plant-wide summary. + + Args: + rows: Normalised journal rows covering the window and the prior window. + activeNow: Rows from alarms._fetchStatus, or an empty list. + shelvedCount: Number of shelved alarm paths, or None. + startMs: Window start, epoch milliseconds. + endMs: Window end, epoch milliseconds. + nowMs: Current time, epoch milliseconds. + opts: Option overrides; may also carry a "filters" key. + + Returns: + The dashboard bundle dict consumed by the Perspective views. + """ + o = optsMerged(opts) + try: + startMs = int(startMs or 0) + endMs = int(endMs or 0) + nowMs = int(nowMs or 0) + except Exception: + startMs, endMs, nowMs = 0, 0, 0 + filters = (opts or {}).get("filters") if opts else None + instances, mode = correlateInstances(rows) + allInstances = instances + allActiveNow = activeNow + instances = applyFilters(instances, filters) + activeNow = applyStatusFilters(activeNow, filters) + + # Filter dropdown options enumerate the UNfiltered data, so applying a + # filter never collapses the available choices. + areas = sorted(set([i["area"] for i in allInstances] + + [parseSource(s.get("source"), s.get("display_path"))["area"] + for s in allActiveNow or []])) + priosSeen = sorted(set([(i["priority"], i["priority_name"]) + for i in allInstances])) + + bundle = _assemble(instances, activeNow, shelvedCount, startMs, endMs, + nowMs, mode, areas, priosSeen, o) + if filtersActive(filters): + # Overview tab stays a plant-wide (unfiltered) summary; every other + # consumer binds the filtered top-level sections. + bundle["overview"] = _assemble(allInstances, allActiveNow, + shelvedCount, startMs, endMs, + nowMs, mode, areas, priosSeen, o) + else: + bundle["overview"] = None + return bundle + + +def filtersActive(filters): + """Report whether any filter would actually narrow the data. + + Args: + filters: Dict with "priorities", "areas", "states" and "search" keys. + + Returns: + True when at least one filter is set. + """ + if not filters: + return False + try: + return bool(filters.get("priorities") or filters.get("areas") or + filters.get("states") or + (filters.get("search") or "").strip()) + except Exception: + return False + + +def _assemble(instances, activeNow, shelvedCount, startMs, endMs, nowMs, + mode, areas, priosSeen, o): + windowH = _safeDiv(endMs - startMs, 3600000.0) or 0.0 + filteredEvents = 0 + for i in instances: + filteredEvents += i.get("n_events", 0) + current, prior, orphans = partitionInstances(instances, startMs, endMs) + + bins = rateBins(current, startMs, endMs, o) + floods = floodEpisodes(bins, current, startMs, endMs, o) + chat = chattering(current, startMs, endMs, o) + fleet = fleeting(current, o) + openCurrent = [i for i in current if i["open"]] + stand = standing(activeNow, openCurrent, nowMs, o) + mm = mttaMttr(current, startMs, endMs, o) + par = pareto(current, o) + prio = priorityDistribution(current, o) + heat = heatmap(current, o) + + # prior mirrors (cheap subset for deltas) + pStart = startMs - (endMs - startMs) + pBins = rateBins(prior, pStart, startMs, o) + pFloods = floodEpisodes(pBins, prior, pStart, startMs, o) + pChat = chattering(prior, pStart, startMs, o) + pFleet = fleeting(prior, o) + pMm = mttaMttr(prior, pStart, startMs, o) + + nAct = len(current) + pAct = len(prior) + rate = _safeDiv(nAct, windowH) + pRate = _safeDiv(pAct, windowH) + unackedNow = len([s for s in activeNow or [] if s.get("unacked")]) + flat = o["delta_flat_pct"] + + kpis = { + "activations": delta(nAct, pAct, True, flat), + "rate_per_hr": delta(rate, pRate, True, flat), + "active_now": delta(len(activeNow or []), None, True, flat), + "unacked_now": delta(unackedNow, None, True, flat), + "shelved_now": delta(shelvedCount, None, True, flat), + "mtta_ms": delta(mm["mtta"]["median_ms"], pMm["mtta"]["median_ms"], True, flat), + "mttr_ms": delta(mm["mttr"]["median_ms"], pMm["mttr"]["median_ms"], True, flat), + "flood_pct": delta(floods["pct_time_in_flood"], pFloods["pct_time_in_flood"], True, flat), + "flood_count": delta(len(floods["episodes"]), len(pFloods["episodes"]), True, flat), + "chatter_count": delta(len(chat), len(pChat), True, flat), + "standing_count": delta(stand["count"], None, True, flat), + "fleeting_count": delta(fleet["total"], pFleet["total"], True, flat), + } + + hasActivity = nAct > 0 + health = healthScore(rate if hasActivity else None, + floods["pct_time_in_flood"] if hasActivity else None, + len(chat), stand["count"] if (activeNow or openCurrent) or hasActivity else None, + prio["sum_abs_dev"], hasActivity, o) + if not hasActivity and not (activeNow or []): + health = {"grade": None, "score": None, "subs": health["subs"]} + + activeNowRows = [] + for s in (activeNow or [])[:int(o["active_now_cap"])]: + parsed = parseSource(s.get("source"), s.get("display_path")) + a = s.get("active_ms") + activeNowRows.append({"source": s.get("source") or "", "label": parsed["label"], + "area": parsed["area"], + "priority_name": s.get("priority_name", "Unknown"), + "active_ms": a, + "age_ms": (nowMs - a) if a is not None else None, + "unacked": bool(s.get("unacked"))}) + + return { + "meta": {"start_ms": startMs, "end_ms": endMs, "prior_start_ms": pStart, + "now_ms": nowMs, "window_hours": windowH, + "event_count": filteredEvents, "activation_count": nAct, + "dropped_rows": 0, "truncated": False, + "correlation_mode": mode, "standing_mode": stand["mode"], + "areas": areas, + "priorities_seen": [{"value": p[0], "label": p[1]} for p in priosSeen], + "source_count": len(set(i["source"] for i in current)), + "error": None, "opts": { + "bin_ms": bins["bin_ms"], "target_per_hr": o["target_per_hr"], + "flood_per_10min": o["flood_per_10min"], + "chatter_per_hr": o["chatter_per_hr"], + "fleeting_s": o["fleeting_s"], "standing_hours": o["standing_hours"]}}, + "kpis": kpis, + "health": health, + "insights": [], + "rate": bins, + "floods": floods, + "priority": prio, + "heatmap": heat, + "mtta_mttr": mm, + "pareto": par, + "top_sources": topSources(par, 5), + "chattering": chat, + "fleeting": fleet, + "standing": stand, + "active_now": activeNowRows, + } diff --git a/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/calc/resource.json b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/calc/resource.json new file mode 100644 index 0000000..5aceefa --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/calc/resource.json @@ -0,0 +1,17 @@ +{ + "scope": "A", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "code.py" + ], + "attributes": { + "hintScope": 2, + "lastModificationSignature": "e8cf57d0aef30237c58323477564a9c4786705971ea155d0b861c359b561fe34", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T21:24:23Z" + } + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/fmt/code.py b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/fmt/code.py new file mode 100644 index 0000000..3aea1d9 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/fmt/code.py @@ -0,0 +1,413 @@ +from __future__ import division, print_function + +import time as _time + +from exchange.alarmAnalysis import calc + +ARROW_UP = u"▲" +ARROW_DOWN = u"▼" +ARROW_FLAT = u"—" + + +def dur(ms): + """Format a duration in milliseconds as a short human string. + + Args: + ms: Duration in milliseconds, or None. + + Returns: + A string such as "45s", "3m 20s", "2h 5m" or "--" when ms is None. + """ + if ms is None: + return "--" + try: + s = int(round(ms / 1000.0)) + except Exception: + return "--" + if s < 60: + return "%ds" % s + m = s // 60 + if m < 60: + return "%dm %ds" % (m, s % 60) if s % 60 else "%dm" % m + h = m // 60 + if h < 24: + return "%dh %dm" % (h, m % 60) if m % 60 else "%dh" % h + d = h // 24 + return "%dd %dh" % (d, h % 24) if h % 24 else "%dd" % d + + +def num(v, dec=0): + """Format a number with thousands separators. + + Args: + v: The number to format, or None. + dec: Decimal places to keep. + + Returns: + The grouped number as a string, or "--" when v is None. + """ + if v is None: + return "--" + try: + if dec: + s = ("%%.%df" % dec) % v + whole, frac = s.split(".") + return _group(whole) + "." + frac + return _group("%d" % round(v)) + except Exception: + return str(v) + + +def _group(digits): + neg = digits.startswith("-") + if neg: + digits = digits[1:] + out = "" + while len(digits) > 3: + out = "," + digits[-3:] + out + digits = digits[:-3] + return ("-" if neg else "") + digits + out + + +def pct(v, dec=0, signed=False): + """Format a number as a percentage string. + + Args: + v: The percentage value (already scaled 0-100), or None. + dec: Decimal places to keep. + signed: True to force a leading + or - sign. + + Returns: + A string such as "12.5%", or "--" when v is None. + """ + if v is None: + return "--" + try: + fmtstr = "%%+.%df%%%%" % dec if signed else "%%.%df%%%%" % dec + return fmtstr % v + except Exception: + return str(v) + + +def clock(ms): + """Format a timestamp as a local wall-clock time. + + Args: + ms: Epoch milliseconds, or None. + + Returns: + A string such as "3:42 PM", or "--" when ms is None. + """ + if ms is None: + return "--" + try: + return _time.strftime("%I:%M %p", _time.localtime(ms / 1000.0)).lstrip("0") + except Exception: + return "--" + + +def dayClock(ms): + """Format a timestamp as an abbreviated weekday plus wall-clock time. + + Args: + ms: Epoch milliseconds, or None. + + Returns: + A string such as "Tue 3:42 PM", or "--" when ms is None. + """ + if ms is None: + return "--" + try: + return _time.strftime("%a ", _time.localtime(ms / 1000.0)) + clock(ms) + except Exception: + return "--" + + +def deltaChip(d): + """Turn a delta dict into arrow, text and good/bad flags for a KPI chip. + + Args: + d: A delta dict from calc.delta, or None. + + Returns: + Dict with "arrow", "text" and "good". + """ + if not d: + return {"arrow": ARROW_FLAT, "text": "--", "good": None} + direction = d.get("dir") + if direction == "up": + arrow = ARROW_UP + elif direction == "down": + arrow = ARROW_DOWN + else: + arrow = ARROW_FLAT + if direction == "new": + text = "new" + elif direction == "none": + text = "--" + elif d.get("delta_pct") is not None: + text = pct(d["delta_pct"], 0, True) + else: + text = "--" + return {"arrow": arrow, "text": text, "good": d.get("good")} + + +def gradeColorKey(grade): + """Map a health grade to the key used by the grade style classes. + + Args: + grade: A grade letter, or None. + + Returns: + The grade letter when it is one of A-F, otherwise "NA". + """ + return grade if grade in ("A", "B", "C", "D", "F") else "NA" + + +# ---------------- insights ---------------- + + +def insights(bundle, opts=None): + """Turn a dashboard bundle into a ranked list of plain-language findings. + + Args: + bundle: The dict returned by calc.buildBundle. + opts: Option overrides, or None for DEFAULTS. + + Returns: + A list of insight dicts with "severity", "icon", "text" and "tab", + capped at the "insights_max" option. Never empty for a valid bundle. + """ + o = calc.optsMerged(opts) + if not bundle: + return [] + out = [] + for rule in (_iRate, _iFloods, _iChatter, _iStanding, + _iPriority, _iPareto, _iFleeting): + try: + r = rule(bundle) + if r: + out.append(r) + except Exception: + pass + out.sort(key=lambda i: (i["severity"], -i.get("magnitude", 0))) + out = out[:int(o["insights_max"])] + if not out: + k = bundle.get("kpis", {}).get("rate_per_hr", {}) + rate = k.get("value") + if bundle.get("meta", {}).get("activation_count", 0) == 0: + text = "No alarm events in the selected range." + else: + text = ("No significant changes vs the prior period; rate %.1f/hr is within " + "the ISA target of %.0f/hr." % (rate or 0.0, + bundle["meta"]["opts"]["target_per_hr"])) + out = [{"severity": 3, "icon": "check_circle", "text": text, + "tab": "overview", "magnitude": 0}] + for i in out: + i.pop("magnitude", None) + return out + + +def _iRate(b): + k = b["kpis"]["activations"] + dp = k.get("delta_pct") + cur = k.get("value") or 0 + if dp is None or cur < 10: + return None + if dp >= 25: + sev = 0 if dp >= 100 else 1 + text = ("Alarm rate up %d%% vs prior period (%s -> %s activations)" + % (round(dp), num(k.get("prior")), num(cur))) + attr = _rateAttribution(b) + if attr: + text += " - largest increase from %s" % attr + return {"severity": sev, "icon": "trending_up", "text": text + ".", + "tab": "badactors", "magnitude": dp} + if dp <= -25: + return {"severity": 3, "icon": "trending_down", + "text": "Alarm rate down %d%% vs prior period - improving." % round(-dp), + "tab": "overview", "magnitude": -dp} + return None + + +def _rateAttribution(b): + rows = b.get("pareto", {}).get("rows") or [] + if rows: + return rows[0]["label"] + return None + + +def _iFloods(b): + eps = b.get("floods", {}).get("episodes") or [] + if not eps: + return None + worst = sorted(eps, key=lambda e: -e["event_count"])[0] + totalMin = sum(e["duration_ms"] for e in eps) / 60000.0 + text = ("%d flood episode(s) totaling %s; worst %s-%s (%d events, mostly %s)." + % (len(eps), dur(totalMin * 60000), dayClock(worst["start_ms"]), + clock(worst["end_ms"]), worst["event_count"], + worst["top_source_label"] or worst["top_area"] or "n/a")) + return {"severity": 0, "icon": "flood", "text": text, "tab": "analysis", + "magnitude": len(eps)} + + +def _iChatter(b): + rows = b.get("chattering") or [] + if not rows: + return None + top = rows[0] + if len(rows) == 1: + text = ("%s is chattering (%.0f re-triggers/hr, median gap %.0fs)." + % (top["label"], top["per_hour"], top["median_gap_s"])) + else: + text = ("%d alarms are chattering (>%.0f re-triggers/hr); worst is %s at %.0f/hr." + % (len(rows), b["meta"]["opts"]["chatter_per_hr"], top["label"], + top["per_hour"])) + return {"severity": 1, "icon": "repeat", "text": text, "tab": "badactors", + "magnitude": len(rows)} + + +def _iStanding(b): + st = b.get("standing") or {} + if not st.get("count"): + return None + rows = st.get("rows") or [] + oldest = rows[0] if rows else None + text = "%d alarm(s) standing >%dh" % (st["count"], b["meta"]["opts"]["standing_hours"]) + if oldest and oldest.get("age_h"): + text += "; oldest is %s at %.1f days" % (oldest["label"], oldest["age_h"] / 24.0) + return {"severity": 1, "icon": "schedule", "text": text + ".", "tab": "badactors", + "magnitude": st["count"]} + + +def _iPriority(b): + p = b.get("priority") or {} + if p.get("sum_abs_dev") is None: + return None + high = p["pct"]["high"] + target = p["target"]["high"] + if high - target >= 10: + return {"severity": 2, "icon": "priority_high", + "text": ("High/Critical alarms are %d%% of events vs %d%% ISA target - " + "priorities may be over-assigned." % (round(high), round(target))), + "tab": "analysis", "magnitude": high - target} + return None + + +def _iPareto(b): + rows = b.get("pareto", {}).get("rows") or [] + for k, row in enumerate(rows[:3]): + if row["cum_pct"] >= 50: + n = k + 1 + return {"severity": 2, "icon": "insights", + "text": ("%d source(s) account for %d%% of all activations - start " + "with %s." % (n, round(row["cum_pct"]), rows[0]["label"])), + "tab": "badactors", "magnitude": row["cum_pct"]} + return None + + +def _iFleeting(b): + fl = b.get("fleeting") or {} + if (fl.get("total") or 0) < 10: + return None + return {"severity": 2, "icon": "bolt", + "text": ("%d fleeting alarms (<%ds active) in range - likely deadband or " + "nuisance issues." % (fl["total"], b["meta"]["opts"]["fleeting_s"])), + "tab": "badactors", "magnitude": fl["total"]} + + +# ---------------- shift report ---------------- + + +def shiftBounds(nowMs, shiftHours=8, anchorHour=0): + """Work out the current and previous shift windows around a timestamp. + + Args: + nowMs: Current time, epoch milliseconds. + shiftHours: Shift length in hours. + anchorHour: Local hour at which the first shift of the day starts. + + Returns: + Dict with "current" and "previous" (startMs, endMs) tuples and a + human-readable "label" for the previous shift. + """ + lt = _time.localtime(nowMs / 1000.0) + midnight = _time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0, + lt.tm_wday, lt.tm_yday, -1)) + anchor = midnight + anchorHour * 3600 + nowS = nowMs / 1000.0 + if nowS < anchor: + anchor -= 86400 + shiftS = shiftHours * 3600 + k = int((nowS - anchor) // shiftS) + curStart = anchor + k * shiftS + cur = (int(curStart * 1000), int((curStart + shiftS) * 1000)) + prev = (int((curStart - shiftS) * 1000), int(curStart * 1000)) + label = "%s - %s" % (dayClock(prev[0]), dayClock(prev[1])) + return {"current": cur, "previous": prev, "label": label} + + +def _kpiLine(name, k, formatter): + chip = deltaChip(k) + val = formatter(k.get("value")) + extra = "" if chip["text"] == "--" else " (%s %s)" % (chip["arrow"], chip["text"]) + return "%-14s %s%s" % (name + ":", val, extra) + + +def shiftReport(bundle, label): + """Render a plain-text shift handover report from a dashboard bundle. + + Args: + bundle: The dict returned by calc.buildBundle for the shift window. + label: Human-readable name of the shift. + + Returns: + Dict with the rendered "text" and the "sections" it was built from. + """ + if not bundle: + return {"text": "No data.", "sections": []} + k = bundle["kpis"] + h = bundle["health"] + subs = " | ".join("%s %s" % (s["key"], "%d" % s["score"] if s["score"] is not None else "n/a") + for s in h.get("subs", [])) + sections = [] + head = ["Shift: %s" % label, + "Generated: %s" % dayClock(bundle["meta"]["now_ms"]), + "Health: %s (%s) [%s]" % (h.get("grade") or "N/A", + "%d" % h["score"] if h.get("score") is not None else "-", + subs)] + sections.append({"heading": "ALARM SHIFT REPORT", "lines": head}) + kpis = [ + _kpiLine("Activations", k["activations"], num), + _kpiLine("Rate/hr", k["rate_per_hr"], lambda v: num(v, 1)), + _kpiLine("MTTA", k["mtta_ms"], dur), + _kpiLine("MTTR", k["mttr_ms"], dur), + _kpiLine("Flood time", k["flood_pct"], lambda v: pct(v, 1)), + _kpiLine("Chattering", k["chatter_count"], num), + _kpiLine("Standing", k["standing_count"], num), + _kpiLine("Fleeting", k["fleeting_count"], num), + ] + sections.append({"heading": "KPIs (vs prior shift)", "lines": kpis}) + actors = ["#%d %-40s %6s %s" % (r["rank"], r["label"][:40], num(r["count"]), + pct(r["cum_pct"])) + for r in bundle["pareto"]["rows"][:5]] or ["none"] + sections.append({"heading": "Top actors (count, cumulative %)", "lines": actors}) + floods = ["%s - %s %s, %d events, top: %s" + % (dayClock(e["start_ms"]), clock(e["end_ms"]), dur(e["duration_ms"]), + e["event_count"], e["top_source_label"] or e["top_area"]) + for e in bundle["floods"]["episodes"]] or ["none"] + sections.append({"heading": "Flood episodes", "lines": floods}) + standingRows = bundle["standing"]["rows"][:8] + stand = ["%-40s active %s%s" % (r["label"][:40], dur(r["age_ms"]), + " (UNACKED)" if r["unacked"] else "") + for r in standingRows] or ["none"] + sections.append({"heading": "Standing / handover attention", "lines": stand}) + sections.append({"heading": "Insights", + "lines": [i["text"] for i in bundle.get("insights") or []] or ["none"]}) + textParts = [] + for s in sections: + textParts.append(s["heading"]) + textParts.append("-" * len(s["heading"])) + textParts.extend(s["lines"]) + textParts.append("") + return {"text": "\n".join(textParts), "sections": sections} diff --git a/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/fmt/resource.json b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/fmt/resource.json new file mode 100644 index 0000000..0cded86 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/ignition/script-python/exchange/alarmAnalysis/fmt/resource.json @@ -0,0 +1,17 @@ +{ + "scope": "A", + "version": 1, + "restricted": false, + "overridable": true, + "files": [ + "code.py" + ], + "attributes": { + "hintScope": 2, + "lastModificationSignature": "359da2a962a9429e8d175cc280a6a33facda2c0995eb4ad29baa019e0a1cfdb1", + "lastModification": { + "actor": "admin", + "timestamp": "2026-07-16T21:24:30Z" + } + } +} \ No newline at end of file diff --git a/ignition/gateway/projects/alarm-analysis/project.json b/ignition/gateway/projects/alarm-analysis/project.json new file mode 100644 index 0000000..526e357 --- /dev/null +++ b/ignition/gateway/projects/alarm-analysis/project.json @@ -0,0 +1,7 @@ +{ + "title": "Alarm Analysis Dashboard", + "description": "A drop-in Perspective dashboard that turns the Ignition alarm journal into actionable metrics: alarm rate, MTTA/MTTR, flood windows, chattering and standing alarms, and a plant alarm-health score. It reads the journal through system.alarm only, so it needs no tags, no SQL and no gateway configuration beyond an alarm journal profile.", + "enabled": true, + "inheritable": false, + "parent": "" +} diff --git a/tools/lint_project.py b/tools/lint_project.py index 724c011..ef4dd64 100644 --- a/tools/lint_project.py +++ b/tools/lint_project.py @@ -20,6 +20,11 @@ Checks (stdlib only, exit non-zero on any FAIL): only PrimeControls; dead view params reported as failures Usage: python3 tools/lint_project.py [--strict] [--project PrimeBAT] + [--namespace PrimeControls] + +--namespace names the single folder every view / style class / script package +must live under. It defaults to PrimeControls (PrimeBAT); the shippable, +de-branded copy passes --project AlarmDashboard --namespace AlarmDashboard. """ import argparse import json @@ -116,7 +121,9 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--strict", action="store_true") ap.add_argument("--project", default="PrimeBAT") + ap.add_argument("--namespace", default="PrimeControls") args = ap.parse_args() + ns = args.namespace proj = os.path.join(ROOT, "ignition", "gateway", "projects", args.project) if not os.path.isdir(proj): @@ -171,8 +178,8 @@ def main(): session_schema = sp.get("custom") or {} if args.strict: for key in session_schema: - if key != "PrimeControls": - fail(rel(sp_path), "session custom key %r outside PrimeControls" % key) + if key != ns: + fail(rel(sp_path), "session custom key %r outside %s" % (key, ns)) # ---------- page-config contest rules ---------- pc_path = os.path.join(proj, PERSPECTIVE, "page-config", "config.json") @@ -181,18 +188,18 @@ def main(): pages = pc.get("pages") or {} if list(pages.keys()) != ["/"]: fail(rel(pc_path), "pages must be exactly ['/'], got %s" % list(pages.keys())) - elif (pages["/"] or {}).get("viewPath") != "PrimeControls/Dashboard": - fail(rel(pc_path), "page '/' must map to PrimeControls/Dashboard") + elif (pages["/"] or {}).get("viewPath") != "%s/Dashboard" % ns: + fail(rel(pc_path), "page '/' must map to %s/Dashboard" % ns) if pc.get("sharedDocks"): fail(rel(pc_path), "sharedDocks must be empty (docked views prohibited)") # ---------- namespace rule: everything under PrimeControls/ ---------- for p in sorted(view_paths): - if not p.startswith("PrimeControls/"): - fail("views/%s" % p, "view outside PrimeControls/ folder") + if not p.startswith(ns + "/"): + fail("views/%s" % p, "view outside %s/ folder" % ns) for p in sorted(style_paths): - if not p.startswith("PrimeControls/"): - fail("style-classes/%s" % p, "style class outside PrimeControls/ folder") + if not p.startswith(ns + "/"): + fail("style-classes/%s" % p, "style class outside %s/ folder" % ns) # ---------- per-view checks ---------- for vp in sorted(view_paths): @@ -222,8 +229,8 @@ def main(): if isinstance(path_prop, str) and path_prop: if path_prop not in view_paths: fail(rv, "%s embeds missing view %r" % (cpath, path_prop)) - elif not path_prop.startswith("PrimeControls/"): - fail(rv, "%s embeds non-PrimeControls view %r" % (cpath, path_prop)) + elif not path_prop.startswith(ns + "/"): + fail(rv, "%s embeds view %r outside %s/" % (cpath, path_prop, ns)) elif not bound: warn(rv, "%s embed with no static path and no binding" % cpath) classes = ((node.get("props") or {}).get("style") or {}).get("classes") @@ -241,7 +248,8 @@ def main(): if btype not in KNOWN_BINDING_TYPES: fail(rv, "%s %s: unknown binding type %r" % (owner, key, btype)) if btype == "tag": - fail(rv, "%s %s: tag binding (prohibited in PrimeBAT — journal API only)" % (owner, key)) + fail(rv, "%s %s: tag binding (prohibited in %s — journal API only)" + % (owner, key, args.project)) text_blobs = [json.dumps(binding.get("config") or {})] for tr in binding.get("transforms") or []: code = tr.get("code", "") @@ -288,8 +296,8 @@ def main(): fail("ignition/%s" % entry, "unexpected project resource type for submission") if os.path.isdir(sp_root): for entry in sorted(os.listdir(sp_root)): - if entry != "PrimeControls": - fail("script-python/%s" % entry, "script package outside PrimeControls") + if entry != ns: + fail("script-python/%s" % entry, "script package outside %s" % ns) for w in WARNS: print(w) diff --git a/tools/seed_journal.py b/tools/seed_journal.py index 8a71f4e..c016d58 100644 --- a/tools/seed_journal.py +++ b/tools/seed_journal.py @@ -4,12 +4,13 @@ The alarm simulator (SimHarness/alarmsim) generates activity in real time, so "give me 8 hours of alarms" would take 8 hours. This writes the same kind of activity straight into the journal tables with backdated timestamps, shaped to -land on a target Alarm Health grade. +land on a target Alarm Health grade or an exact Alarm Health score. - python3 tools/seed_journal.py --hours 8 --grade B --dry-run # plan only - python3 tools/seed_journal.py --hours 8 --grade B # insert + python3 tools/seed_journal.py --hours 8 --score 85 --dry-run # plan only + python3 tools/seed_journal.py --hours 8 --score 85 # insert + python3 tools/seed_journal.py --hours 8 --grade B # canned profile -How the grade is hit (weights from PrimeControls.calc.DEFAULTS): +How the grade/score is hit (weights from PrimeControls.calc.DEFAULTS): rate 0.30 activations/hr vs the 6/hr ISA target flood 0.25 % of window inside a flood episode (>10 activations/10 min) @@ -19,11 +20,28 @@ How the grade is hit (weights from PrimeControls.calc.DEFAULTS): Only `rate`, `flood` and `priority` are seedable. `standing` reads live gateway alarm state via system.alarm.queryStatus, so whatever is actually active now is -measured as-is and the profile budgets for it (--standing-now). +measured as-is and the profile budgets for it (--standing-now). `chatter` is +structurally 0 (SOURCE_CAP < calc's chatter_min_count). + +--score solves for the three seedable dials instead of using a canned profile: +total activations (rate), whether exactly one 10-min cell floods, and the exact +low/medium/high split (priority deviation). Rows already in the window - a live +active alarm, an earlier seed - are read back from the journal and counted as +part of the target, so the solve describes the window the gateway will actually +score. Two shapes are available: + + --shape stable (default) rate held at/below the ISA target, so the rate + sub-score sits pinned at 100 on the flat part of its curve + and the score does NOT drift as the dashboard's rolling + window slides forward and old activations fall out. The + whole deficit lands on flood + priority mix. + --shape balanced deficit spread across rate, flood and priority - a more + typical-looking plant, but the score climbs a few tenths + per 10 minutes as the window slides past the seeded data. The plan is verified offline against the real PrimeControls.calc before anything is written - the same module the gateway runs - and the run aborts if the -predicted grade misses the target. +predicted grade/score misses the target. Journal conventions this mirrors (harvested from existing rows): eventtype 0=active 1=clear 2=ack eventflags 0 for tag alarms @@ -101,6 +119,12 @@ MAX_DURATION_S = 420 ACK_RATE = 0.85 CLEAR_RATE = 0.94 +# --score solver +BURST_COUNT = 11 # > calc flood_per_10min (10) => one flooding cell +SOLVE_N_MAX = 260 # activation ceiling searched (32/hr over 8 h) +PRIOR_WORSE = 1.25 # prior-window activations, as a multiple of current +HIGH_SHARE_PRIOR = 0.18 # tie-break: how much of the mix should be High/Critical + # ---------------- the gateway's own calc module ---------------- @@ -160,6 +184,105 @@ def cell_start(ms): return (ms // BIN_MS) * BIN_MS +def bucket_capacity(sources): + """Activations each bucket can absorb without any source reaching + SOURCE_CAP - i.e. without manufacturing a chattering alarm.""" + cap = {"low": 0, "medium": 0, "high": 0} + for rel, lvl in sources: + cap[BUCKET_OF[lvl]] += SOURCE_CAP + return cap + + +def solve_spec(calc, target, hours, standing_now, existing, shape, tol, capacity): + """Search the seedable dial space for a window that scores `target`. + + Dials: total activations N (rate sub-score), whether exactly one 10-min cell + floods (flood sub-score), and the integer low/medium/high split (priority + deviation). chatter is structurally 0 and standing is live gateway state, so + both are inputs. Every candidate's score comes from the gateway's own + calc.health_score, and `existing` (activations already in the window, per + bucket) is part of the total - only the remainder gets seeded. + + Returns a PROFILES-style spec for the seeded remainder, plus the totals and + the predicted sub-scores for reporting. + """ + span_ms = int(hours * HOUR_MS) + ex_total = sum(existing.values()) + weight_p = calc.DEFAULTS["health_weights"]["priority"] + ptarget = calc.DEFAULTS["priority_target"] + cands = [] + + for flood_bins in (1, 0): + pct_flood = 100.0 * flood_bins * BIN_MS / span_ms + burst = BURST_COUNT if flood_bins else 0 + for n in range(max(ex_total, burst, 1), SOLVE_N_MAX + 1): + rate = n / hours + # score(dev) is linear in the priority deviation while priority_s > 0, + # so two probes give the deviation the target needs - no duplicated + # copy of calc's weighting here. + base = calc.health_score(rate, pct_flood, 0, standing_now, 0.0, True)["score"] + slope = base - calc.health_score(rate, pct_flood, 0, standing_now, + 1.0, True)["score"] + if slope <= 0: + continue + dev_needed = (base - target) / slope + if dev_needed < 0 or dev_needed > 100.0 / max(weight_p, 1e-9): + continue + best = None + hi_max = min(int(0.30 * n), existing["high"] + capacity["high"]) + med_max = min(int(0.40 * n), existing["medium"] + capacity["medium"]) + for h in range(existing["high"], hi_max + 1): + for m in range(existing["medium"], med_max + 1): + lo = n - m - h + if lo < existing["low"] or lo < m or lo < h: + continue # low stays the biggest bucket + if lo - existing["low"] > capacity["low"]: + continue # more low activations than sources can carry + dev = (abs(100.0 * lo / n - ptarget["low"]) + + abs(100.0 * m / n - ptarget["medium"]) + + abs(100.0 * h / n - ptarget["high"])) + key = (abs(dev - dev_needed), abs(h / float(n) - HIGH_SHARE_PRIOR)) + if best is None or key < best[0]: + best = (key, lo, m, h, dev) + if best is None: + continue + _key, lo, m, h, dev = best + hs = calc.health_score(rate, pct_flood, 0, standing_now, dev, True) + miss = abs(hs["score"] - target) + if miss > tol: + continue + subs = dict((s["key"], s["score"]) for s in hs["subs"]) + cands.append({"flood_bins": flood_bins, "burst": burst, "n": n, + "total": {"low": lo, "medium": m, "high": h}, + "dev": dev, "score": hs["score"], "grade": hs["grade"], + "subs": subs, "miss": miss}) + if not cands: + raise SystemExit( + "no seedable window scores %.2f +/-%.2f with %d standing alarm(s) and " + "%d activation(s) already in the window (score reachable range with " + "these inputs is roughly 60-100; wipe the window or adjust --hours)" + % (target, tol, standing_now, ex_total)) + + def rank(c): + # accuracy first, in 0.02-score buckets (the UI shows one decimal), then + # the shape preference, then more data over less. + bucket = int(c["miss"] / 0.02) + if shape == "stable": + shape_cost = (0 if c["subs"]["rate"] >= 100.0 else 1, + abs(c["total"]["high"] / float(c["n"]) - HIGH_SHARE_PRIOR)) + else: + # spread the deficit: penalise deep single-sub holes quadratically + spread = sum((100.0 - c["subs"][k]) ** 2 * w for k, w in + calc.DEFAULTS["health_weights"].items() + if c["subs"].get(k) is not None) + shape_cost = (round(spread / 100.0), 0.0) + return (bucket, shape_cost, -c["n"]) + + best = sorted(cands, key=rank)[0] + best["counts"] = dict((b, best["total"][b] - existing[b]) for b in best["total"]) + return best + + def allocate(sources, counts, burst_count, burst_area, rng): """Pick (rel, level) for every activation, honouring the exact bucket split and SOURCE_CAP. Returns (burst_picks, baseline_picks).""" @@ -194,12 +317,14 @@ def allocate(sources, counts, burst_count, burst_area, rng): return burst, baseline -def place_times(start_ms, end_ms, burst, baseline, burst_at_ms, rng): +def place_times(start_ms, end_ms, burst, baseline, burst_at_ms, rng, pre_cells=None): """Assign an activation timestamp to every pick. Burst picks land inside the single 10-minute cell containing burst_at_ms (with an edge margin so they cannot spill into the neighbouring cell). Baseline picks spread over the remaining cells, capped at BASELINE_BIN_CAP. + `pre_cells` seeds the per-cell counter with activations already in the + journal, so the cap accounts for them too. """ burst_cell = cell_start(burst_at_ms) events = [] @@ -225,7 +350,7 @@ def place_times(start_ms, end_ms, burst, baseline, burst_at_ms, rng): raise SystemExit("baseline %d exceeds capacity %d (cap %d/cell over %d cells)" % (len(baseline), capacity, BASELINE_BIN_CAP, len(cells))) - per_cell = collections.Counter() + per_cell = collections.Counter(pre_cells or {}) for pick in baseline: # Free choice among under-cap cells, so activity clumps the way real # activity does. BASELINE_BIN_CAP (4) is below both the flood-start @@ -268,14 +393,14 @@ def build_instances(events, end_ms, rng): return instances -def plan_window(sources, spec, start_ms, end_ms, rng): +def plan_window(sources, spec, start_ms, end_ms, rng, pre_cells=None): counts = spec["counts"] burst_hours, burst_count = spec["burst"] burst, baseline = allocate(sources, counts, burst_count, spec.get("burst_area"), rng) burst_at = end_ms - int(burst_hours * HOUR_MS) events, burst_cell, per_cell = place_times(start_ms, end_ms, burst, baseline, - burst_at, rng) + burst_at, rng, pre_cells) instances = build_instances(events, end_ms, rng) return instances, burst_cell, per_cell @@ -371,6 +496,61 @@ def max_id(): return int(out.split("\n")[0]) +def parse_local(text): + """'YYYY-MM-DD HH:MM:SS' in GATEWAY_TZ (how the journal stores it) -> ms.""" + dt = datetime.strptime(text.strip(), "%Y-%m-%d %H:%M:%S") + if ZoneInfo is not None: + dt = dt.replace(tzinfo=ZoneInfo(GATEWAY_TZ)) + return int(dt.timestamp() * 1000) + + +STATE_OF_EVENTTYPE = {0: "active", 1: "clear", 2: "ack"} + + +def read_existing(calc, start_ms, end_ms): + """Journal rows already in [start_ms, end_ms], shaped like the dicts + alarms._norm_event hands calc - so the offline prediction sees the window the + gateway will actually score, not just the rows this run adds. + + Ack attribution is left off: it feeds MTTA/ack-user lists, never health. + """ + out = mariadb("SELECT eventid,source,priority,eventtype,eventtime FROM %s " + "WHERE eventtime >= '%s' AND eventtime <= '%s' ORDER BY eventtime;" + % (EVENTS_TABLE, local_dt(start_ms), local_dt(end_ms))) + rows = [] + for line in (out or "").split("\n"): + if not line.strip(): + continue + eventid, source, priority, etype, eventtime = line.split("\t") + state = STATE_OF_EVENTTYPE.get(int(etype)) + if state is None: + continue + lvl, name = calc.normalize_priority(int(priority)) + rows.append({"event_id": eventid, "source": source, "display_path": "", + "priority": lvl, "priority_name": name, "state": state, + "ts": parse_local(eventtime), "ack_user": None, + "is_system": source.startswith("evt:") or ":/alm:" not in source}) + return rows + + +def existing_activations(rows, start_ms, end_ms): + """(bucket counts, per-10-min-cell counts) for the real activations already + inside the scored window - the head start the solver has to plan around.""" + buckets = {"low": 0, "medium": 0, "high": 0} + cells = collections.Counter() + for r in rows: + if r["is_system"] or r["state"] != "active": + continue + if not (start_ms <= r["ts"] <= end_ms): + continue + bucket = BUCKET_OF.get(r["priority"]) + if bucket is None: + continue # unbucketed priority: calc ignores it + buckets[bucket] += 1 + cells[cell_start(r["ts"])] += 1 + return buckets, cells + + # ---------------- reporting ---------------- @@ -383,13 +563,44 @@ def show_health(label, bundle): % (sub["key"], sub["weight"], score, sub["detail"])) +def prior_spec(total_counts, burst_count, burst_hours, existing_prior, capacity): + """A deliberately worse preceding window, so the period-over-period delta + chips read as improving. Never scored - only the chips consume it, so its + counts just get clamped to what the source pool can carry.""" + counts = {} + for bucket in total_counts: + want = int(round(total_counts[bucket] * PRIOR_WORSE)) + counts[bucket] = max(0, min(want - existing_prior.get(bucket, 0), + capacity[bucket])) + burst = burst_count + 2 if burst_count else 0 + if sum(counts.values()) < burst: + burst = 0 + return {"counts": counts, "burst": (burst_hours + 0.4, burst), + "burst_area": "BoilerHouse"} + + def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--hours", type=float, default=8.0, help="window length; must match the dashboard preset (default 8)") - ap.add_argument("--grade", default="B", choices=sorted(PROFILES), - help="target Alarm Health grade (default B)") + ap.add_argument("--score", type=float, default=None, + help="target Alarm Health score (e.g. 85). Solves for the " + "seedable dials instead of using a canned profile") + ap.add_argument("--grade", default=None, choices=sorted(PROFILES), + help="target Alarm Health grade via a canned profile " + "(assumed to be B when --score is omitted)") + ap.add_argument("--shape", default="stable", choices=("stable", "balanced"), + help="--score only: stable holds the rate sub-score pinned at 100 so " + "the score does not drift as the rolling window slides; balanced " + "spreads the deficit across rate/flood/priority (default stable)") + ap.add_argument("--tolerance", type=float, default=0.05, + help="--score only: allowed miss on the predicted score; the UI shows " + "one decimal (default 0.05)") + ap.add_argument("--burst-hours", type=float, default=2.2, + help="--score only: put the flood burst this many hours before the " + "window end - it survives window drift until it ages out " + "(default 2.2)") ap.add_argument("--standing-now", type=int, default=1, help="live alarms active >24h, which cost 20 pts each on the " "standing sub-score. NOT seedable - standing() reads " @@ -397,39 +608,101 @@ def main(): "the SimHarness ProbeTick probe (default 1)") ap.add_argument("--no-prior", action="store_true", help="skip the preceding comparison window (delta chips go 'new')") + ap.add_argument("--attempts", type=int, default=6, + help="re-plan with a fresh rng draw this many times before giving up " + "(placement can interact with rows already in the window)") ap.add_argument("--seed", type=int, default=20260731) ap.add_argument("--dry-run", action="store_true", help="plan and predict only; write nothing") args = ap.parse_args() + if args.score is None and args.grade is None: + args.grade = "B" calc = load_calc() sources = load_sources() - rng = random.Random(args.seed) now_ms = int(datetime.now().timestamp() * 1000) span = int(args.hours * HOUR_MS) end_ms, start_ms = now_ms, now_ms - span prior_start = start_ms - span - - profile = PROFILES[args.grade] + standing_n = args.standing_now print("window %s -> %s (%.1f h)" % (local_dt(start_ms), local_dt(end_ms), args.hours)) print("sources %d real sim alarms (bad-actor tags excluded)" % len(sources)) - cur, burst_cell, per_cell = plan_window(sources, profile["current"], - start_ms, end_ms, rng) - instances = list(cur) - if not args.no_prior: - pri, _, _ = plan_window(sources, profile["prior"], prior_start, start_ms, rng) - instances = pri + instances - - standing_n = args.standing_now + # Rows already in the window (a live active alarm, an earlier seed) are part + # of what the gateway will score, so they are part of the target. + existing_rows = read_existing(calc, prior_start, end_ms) + ex_buckets, ex_cells = existing_activations(existing_rows, start_ms, end_ms) + ex_prior, _ = existing_activations(existing_rows, prior_start, start_ms - 1) + print("existing %d journal row(s) in the window -> %d activation(s) %d/%d/%d " + "low/medium/high (folded into the target)" + % (len([r for r in existing_rows if start_ms <= r["ts"] <= end_ms]), + sum(ex_buckets.values()), ex_buckets["low"], ex_buckets["medium"], + ex_buckets["high"])) print("standing %d live alarm(s) >24h (measured, not seeded)" % standing_n) - rows = to_journal_rows(instances, calc) - active_now = synthetic_standing(calc, standing_n, now_ms) - bundle = predict(calc, rows, active_now, start_ms, end_ms, now_ms) + if args.score is not None: + capacity = bucket_capacity(sources) + solved = solve_spec(calc, args.score, args.hours, standing_n, ex_buckets, + args.shape, args.tolerance, capacity) + spec_current = {"counts": solved["counts"], + "burst": (args.burst_hours, solved["burst"]), + "burst_area": "Packaging"} + spec_prior = prior_spec(solved["total"], solved["burst"], args.burst_hours, + ex_prior, capacity) + t = solved["total"] + print() + print("solved (%s shape) for score %.2f:" % (args.shape, args.score)) + print(" %d activations in window (%.2f/hr), %d/%d/%d low/medium/high, " + "dev %.2f, %d flooding 10-min cell(s)" + % (solved["n"], solved["n"] / args.hours, t["low"], t["medium"], + t["high"], solved["dev"], solved["flood_bins"])) + print(" sub-scores rate %.1f flood %.1f chatter %.1f standing %.1f " + "priority %.1f -> %.4f (%s)" + % (solved["subs"]["rate"], solved["subs"]["flood"], + solved["subs"]["chatter"], solved["subs"]["standing"], + solved["subs"]["priority"], solved["score"], solved["grade"])) + print(" to seed: %d/%d/%d low/medium/high (%d activations)" + % (solved["counts"]["low"], solved["counts"]["medium"], + solved["counts"]["high"], sum(solved["counts"].values()))) + else: + profile = PROFILES[args.grade] + spec_current = profile["current"] + spec_prior = profile["prior"] + + def attempt(seed): + rng = random.Random(seed) + cur, _burst_cell, per_cell = plan_window(sources, spec_current, start_ms, + end_ms, rng, ex_cells) + instances = list(cur) + if not args.no_prior: + pri, _, _ = plan_window(sources, spec_prior, prior_start, start_ms, rng) + instances = pri + instances + rows = existing_rows + to_journal_rows(instances, calc) + active_now = synthetic_standing(calc, standing_n, now_ms) + bundle = predict(calc, rows, active_now, start_ms, end_ms, now_ms) + return cur, instances, rows, per_cell, bundle + + def missed(bundle): + if args.score is not None: + s = bundle["health"]["score"] + if s is None: + return "no score (empty window)" + if abs(s - args.score) > args.tolerance: + return "score %.4f != %.2f +/-%.2f" % (s, args.score, args.tolerance) + return None + g = bundle["health"]["grade"] + return None if g == args.grade else "grade %s != %s" % (g, args.grade) + + why = None + for i in range(max(1, args.attempts)): + cur, instances, rows, per_cell, bundle = attempt(args.seed + i) + why = missed(bundle) + if why is None: + break + print(" attempt %d missed (%s); re-planning" % (i + 1, why)) m = bundle["meta"] print() @@ -444,18 +717,22 @@ def main(): pct = bundle["priority"]["pct"] print(" priority %.1f/%.1f/%.1f vs 80/15/5 (dev %.1f)" % (pct["low"], pct["medium"], pct["high"], bundle["priority"]["sum_abs_dev"])) - print(" busiest 10-min cell (non-burst): %d burst cell: %d" - % (max(per_cell.values()) if per_cell else 0, - profile["current"]["burst"][1])) + print(" busiest 10-min cell (burst cell excluded): %d burst cell: %d" + % (max([c for k, c in per_cell.items() + if c < spec_current["burst"][1] or not spec_current["burst"][1]] or [0]), + spec_current["burst"][1])) show_health("health", bundle) - grade = bundle["health"]["grade"] - if grade != args.grade: - raise SystemExit("\nABORT: predicted grade %s != target %s; nothing written." - % (grade, args.grade)) - print("\nprediction matches target grade %s" % args.grade) + if why is not None: + raise SystemExit("\nABORT after %d attempt(s): %s; nothing written." + % (max(1, args.attempts), why)) + if args.score is not None: + print("\nprediction matches target score %.2f (%.4f, grade %s)" + % (args.score, bundle["health"]["score"], bundle["health"]["grade"])) + else: + print("\nprediction matches target grade %s" % args.grade) - event_rows = sum(1 for _ in rows) + event_rows = len(rows) - len(existing_rows) if args.dry_run: print("dry run: would insert %d event rows for %d activations " "(%d in the scored window)"