├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── get-historical-metrics.py ├── process-agent-event.py ├── render-wallboard.py ├── wallboard-cfn.yaml ├── wallboard-editor.html ├── wallboard-example.html └── wallboard-import.py /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/aws-samples/aws-serverless-connect-wallboard/issues), or [recently closed](https://github.com/aws-samples/aws-serverless-connect-wallboard/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-samples/aws-serverless-connect-wallboard/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/aws-samples/aws-serverless-connect-wallboard/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## AWS Serverless Connect Wallboard 2 | ## Introduction 3 | `aws-serverless-connect-wallboard` provides a way to build near real-time dashboards for your [Amazon Connect](https://aws.amazon.com/connect) contact center. It was introduced [in this blog post](https://aws.amazon.com/blogs/contact-center/building-a-serverless-contact-center-wallboard-for-amazon-connect/) which has further details about architecture and initial setup. These instructions focus more on how to use the software to create wallboards/dashboards. 4 | 5 | First, you will need an Amazon Connect instance up and running. If you wish to track state of agents on your wallboard then you will need to [configure an agent event stream](https://docs.aws.amazon.com/connect/latest/adminguide/agent-event-streams.html) - make note of the ARN. 6 | 7 | Next, you will need to deploy the [CloudFormation template](https://github.com/aws-samples/aws-serverless-connect-wallboard/blob/master/wallboard-cfn.yaml) which will build the Lambda functions, DynamoDB table and API Gateway components for you. 8 | 9 | The DynamoDB table holds data that will be displayed on the wallboard (this data is refreshed periodically - see below for more information) and the configuration for the wallboard. You can edit the DynamoDB table directly to change the look of your wallboard but it's probably easier to edit a YAML definition file as described below. You can have multiple wallboard definitions contained in a single DynamoDB table. You can also collect historical and real-time data from multiple Connect instances. If you wish to collect agent events from multiple Connect instances you will need to configure Kinesis to deliver the events to Lambda manually (which will be processed by the [agent event handler](https://github.com/aws-samples/aws-serverless-connect-wallboard/blob/master/process-agent-event.py)). 10 | ### Wallboard Configuration 11 | Use the [wallboard import utility](https://github.com/aws-samples/aws-serverless-connect-wallboard/blob/master/wallboard-import.py) to import a YAML definition file for each wallboard into DynamoDB. 12 | 13 | Each definition file will use the following format. Note that many parameters are optional - mandatory ones are marked. 14 | ```yaml 15 | WallboardTemplateFormatVersion: 1 16 | Description: 17 | Identifier: 18 | 19 | Defaults: 20 | TextColor: 21 | BackgroundColor: 22 | TextSize: 23 | Font: 24 | WarningBackgroundColor: 25 | AlertBackgroundColor: 26 | 27 | Sources: 28 | - Source: 29 | Description: 30 | Reference: 31 | 32 | Thresholds: 33 | - Threshold: 34 | Reference: 35 | WarnBelow: 36 | AlertBelow: 37 | 38 | Calculations: 39 | - Calculation: 40 | Formula: 41 | 42 | AgentStates: 43 | - State: 44 | Color: 45 | 46 | Rows: 47 | - Row: 48 | Cells: 49 | - Cell: 50 | Text: 51 | TextColor: 52 | BackgroundColor: 53 | TextSize: 54 | Reference: 55 | Format: Time 56 | ThresholdReference: 57 | Rows: 58 | Cells: 59 | ``` 60 | The `Format` parameter is used for converting a numeric data point (which should contain an integer specifying seconds) into a HH:MM:SS string. The only format supported currently is `Time`. Any other format type will be ignored. If this paramter is used on string data it will be ignored. 61 | ### Browser-based Editing 62 | While you can manually edit your wallboard configuration file you might instead try using the [browser-based editing tool](wallboard-editor.html). An understanding of the topics below is still going to be important but the editor allows for each parameter in the wallboard to be entered and the configuration file is built automatically. 63 | 64 | Note that cells can be dragged/dropped to rearrange your wallboard. 65 | ### References 66 | When specifying references to data in Amazon Connect the format for each is as follows: 67 | ```yaml 68 | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy:AGENTS_AVAILABLE 69 | ``` 70 | 71 | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` is the Connect instance identifier. For example, if an instance has an ARN of `arn:aws:connect:us-east-1:111122223333:instance/12345678-1234-1234-1234-123456789012` then you want to use `12345678-1234-1234-1234-123456789012` as the first part of the reference. You can retrieve the Connect instance id directly from the AWS command-line tool by running `aws connect list-instances` and using the `Id` field. 72 | 73 | `yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy` is the Connect queue identifier. For example, if a queue has an ARN of `arn:aws:connect:us-east-1:111122223333:instance/12345678-1234-1234-1234-123456789012/queue/87654321-4321-4321-4321-210987654321` then you want to use `87654321-4321-4321-4321-210987654321` as the second part of the reference. You can retrieve the Queue id directly from the command-line by running `aws conenct list-queues --instance-id ` and using the `Id` field. 74 | 75 | Finally, you need to specify the metric that you wish to reference. There are many metrics available and the wallboard will retrieve both real-time and historical metrics for you without you needing to specify which is which. In this version, the wallboard supports the following metrics for each queue: 76 | - CONTACTS_QUEUED 77 | - CONTACTS_HANDLED 78 | - CONTACTS_ABANDONED 79 | - CONTACTS_CONSULTED 80 | - CONTACTS_AGENT_HUNG_UP_FIRST 81 | - CONTACTS_HANDLED_INCOMING 82 | - CONTACTS_HANDLED_OUTBOUND 83 | - CONTACTS_HOLD_ABANDONS 84 | - CONTACTS_TRANSFERRED_IN 85 | - CONTACTS_TRANSFERRED_OUT 86 | - CONTACTS_TRANSFERRED_IN_FROM_QUEUE 87 | - CONTACTS_TRANSFERRED_OUT_FROM_QUEUE 88 | - CALLBACK_CONTACTS_HANDLED 89 | - API_CONTACTS_HANDLED 90 | - CONTACTS_MISSED 91 | - OCCUPANCY 92 | - HANDLE_TIME 93 | - AFTER_CONTACT_WORK_TIME 94 | - QUEUED_TIME 95 | - ABANDON_TIME 96 | - QUEUE_ANSWER_TIME 97 | - HOLD_TIME 98 | - INTERACTION_TIME 99 | - INTERACTION_AND_HOLD_TIME 100 | - SERVICE_LEVEL 101 | - AGENTS_AVAILABLE 102 | - AGENTS_ONLINE 103 | - AGENTS_ON_CALL 104 | - AGENTS_STAFFED 105 | - AGENTS_AFTER_CONTACT_WORK 106 | - AGENTS_NON_PRODUCTIVE 107 | - AGENTS_ERROR 108 | - CONTACTS_IN_QUEUE 109 | - OLDEST_CONTACT_AGE 110 | - CONTACTS_SCHEDULED 111 | 112 | See the [GetCurrentMetricData API documentation](https://docs.aws.amazon.com/connect/latest/APIReference/API_GetCurrentMetricData.html) for a complete list and description of real-time metrics and the [GetMetric API documentation](https://docs.aws.amazon.com/connect/latest/APIReference/API_GetMetricData.html) for historical metrics. 113 | 114 | In a cell, you specify the data that you wish to display by using the `Reference` tag. The data can be a direct reference (i.e. data that is being drawn from Connect directly); it can be the result of a calculation (several metrics that have been somehow modified - see below); or it can be the name of an agent (see below). Note that cell data can also be static - you may want to display a heading for a column or description for a cell. 115 | #### Special note about SERVICE_LEVEL 116 | Thanks to `eaagastr` for pointing this out. 117 | 118 | SERVICE_LEVEL is an historical metric that requires an additional parameter: Threshold. This is because the metric is determining what the service level is of a queue and therefore needs the number of seconds that it should evaluate the service level over. 119 | 120 | For the time being, there is a small hack into the code so that it doesn't throw an error when SERVICE_LEVEL is requested as a metric. At the top of `get-historical-metrics.py` you'll see a variable which is `ServiceLevelThreshold` and it is set to 60 (seconds). This is static across all queues - you can change this value (between 1 and 604800 inclusive) but you can't set it individually per queue. 121 | 122 | In future, this might change - so that you can specify a different threshold per queue. If this is of interest, create a GitHub issue. 123 | ### Calculations 124 | Calculations allow you to take metrics and perform simple mathematical operations on them. For example, you may have three queues and wish to display the total number of callers for all three queues. To do this, you could use the following snippet: 125 | ```yaml 126 | Sources: 127 | - Source: Queue1Waiting 128 | Description: Callers waiting in Queue 1 129 | Reference: 12345678-1234-1234-1234-123456789012:87654321-4321-4321-4321-210987654321:CONTACTS_IN_QUEUE 130 | - Source: Queue2Waiting 131 | Description: Callers waiting in Queue 2 132 | Source: 12345678-1234-1234-1234-123456789012:87654321-4321-4321-4321-543210987544:CONTACTS_IN_QUEUE 133 | - Source: Queue3Waiting 134 | Description: Callers waiting in Queue 3 135 | Reference: 12345678-1234-1234-1234-123456789012:87654321-4321-4321-4321-568295214776:CONTACTS_IN_QUEUE 136 | 137 | Calculations: 138 | - Calculation: TotalCallersWaiting 139 | Formula: Queue1Waiting+Queue2Waiting+Queue3Waiting 140 | 141 | Rows: 142 | - Row: 1 143 | Cells: 144 | - Cell: 1 145 | Text: Queue 1 146 | Reference: Queue1Waiting 147 | - Cell: 2 148 | Text: Queue 2 149 | Reference: Queue2Waiting 150 | - Cell: 3 151 | Text: Queue 3 152 | Reference: Queue3Waiting 153 | - Cell: 4 154 | Text: Total 155 | Reference: TotalCallersWaiting 156 | ``` 157 | Note that simple mathematical functions such as `int()`, `round()`, `min()` and `max()` are supported in calculations. 158 | ### Thresholds 159 | Setting thresholds lets you change the background colour of a cell based on the value in that cell or in another cell. You can use this to highlight when (for example) to warn you before a SLA is breached (say, when the maximum waiting time is over two minutes) by setting a threshold at one minute using the `WarnAbove` value and another threshold at two miuntes to show the breach using the `AlertAbove` value. 160 | 161 | ```yaml 162 | Sources: 163 | - Source: LongestWaiting 164 | Description: Longest waiting caller 165 | Reference: 12345678-1234-1234-1234-123456789012:87654321-4321-4321-4321-210987654321:OLDEST_CONTACT_AGE 166 | 167 | Thresholds: 168 | - Threshold: LongestWaitingWarning 169 | Reference: LongestWaiting 170 | WarnAbove: 60 171 | AlertAvboe: 120 172 | 173 | Rows: 174 | - Row: 1 175 | Cells: 176 | - Cell: 1 177 | Text: Longest waiting 178 | Reference: LongestWaiting 179 | ThresholdReference: LongestWaitingWarning 180 | ``` 181 | You can apply the threshold reference to any other cells even if they do not contain the data that is causing the breach of threshold. That way, you could turn a whole row or column yellow or red (the default colours) to highlight a threshold breach. Thresholds may also reference the output of calculations rather than raw data from Connect. 182 | 183 | In addition to `WarnAbove` and `AlertAbove` there are also `WarnBelow` and `AlertBelow` keywords. You may wish to create visible warnings and alerts when metrics are below a certain value. For example, you might want to know when there are less than a specific number of agents available to answer calls. 184 | ### Agent states 185 | Make sure that you define colours for each agent state that has been created in Connect. There are no default colours in the wallboard for each state so if a state is detected that doesn't have a colour, the default background colour applies. 186 | 187 | To show an agent state in a cell you do not need to create a reference, the login name of the agent is all that is required: 188 | ```yaml 189 | AgentStates: 190 | - State: Available 191 | Color: Green 192 | - State: Offline 193 | Color: Red 194 | - State: Work 195 | Color: Orange 196 | - State: Lunch 197 | Color: Yellow 198 | 199 | Rows: 200 | - Row: 1 201 | Cells: 202 | - Cell: 1 203 | Description: Agent state for Alice 204 | Text: Alice 205 | Reference: alice 206 | - Cell: 2 207 | Description: Agent state for Bob 208 | Text: Bob 209 | Reference: bob 210 | - Cell: 3 211 | Description: Agent state for Carlos 212 | Text: Carlos 213 | Reference: carlos 214 | - Row: 2 215 | Cells: 216 | - Cell: 1 217 | Description: Agent state for Dave 218 | Text: Dave 219 | Reference: dave 220 | - Cell: 2 221 | Description: Agent state for Erin 222 | Text: Erin 223 | Reference: erin 224 | - Cell: 3 225 | Description: Agent state for Eve 226 | Text: Eve 227 | Reference: eve 228 | ``` 229 | In this snippet we display the name of the agent (using the `Text` tag) but the `Reference` points to the login name for that agent. The wallboard will automatically update each cell with the appropriate colour based on each agent's current state in Connect. 230 | 231 | In a large contact centre, you may not want to have a static list of agents. You may have different agents on different shifts and that would require you to update the wallboard configuration at shift change. Instead, there are two meta values you can use to display a dynamic list of names. 232 | ```yaml 233 | Rows: 234 | - Row: 1 235 | Cells: 236 | - Cell: 1 237 | Description: Agent state 238 | Reference: =allagents 239 | - Cell: 2 240 | Description: Agent state 241 | Reference: =allagents 242 | - Cell: 3 243 | Text: Carlos 244 | Reference: =allagents 245 | ``` 246 | This takes the list of agents from Connect and displays them without you needing to know the names in advanced. Make sure that the `First name` and `Last name` attributes for the agent are filled in as these are used in place of the `Text` tag to display the name of the agent. If you have more agents than cells available then additional agents are not displayed. If you have less agents than cells then the cells are left blank. 247 | 248 | It may not be useful to display the state of agents who are not currently logged into the system. Instead you might wish to only display the state of agents that are active. 249 | ```yaml 250 | Rows: 251 | - Row: 1 252 | Cells: 253 | - Cell: 1 254 | Description: Agent state 255 | Reference: =activeagents 256 | - Cell: 2 257 | Description: Agent state 258 | Reference: =activeagents 259 | - Cell: 3 260 | Text: Carlos 261 | Reference: =activeagents 262 | ``` 263 | Here the cells will only contain details of agents who are not in a `Logout` state. 264 | ### Loading Wallboard Configuration Files 265 | Once you have your YAML definition file you need to import it into the DynamoDB table. To do this you'll need the [import utility](https://github.com/aws-samples/aws-serverless-connect-wallboard/blob/master/wallboard-import.py): 266 | ```sh 267 | ./wallboard-import.py 268 | ``` 269 | ### Calling the API 270 | Once imported you can call the API Gateway endpoint that the CloudFormation template configured for you. You can find this in the `Outputs` section of the CloudFormation stack. 271 | ``` 272 | curl https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/stagename/wallboard?Wallboard=standard 273 | ``` 274 | This example retrieves the wallboard called `standard` which is the name given to it by the `Identifier` tag in the definition file. You can have multiple definitions coexisting in the wallboard system as long as they have unique identifiers. This allows you to have a single set of data that is displayed differently in many locations. For example, you might have an overarching wallboard shown on a large display and also have less complex wallboards that show a subset of the data on agent desktops in a browser. 275 | 276 | By default the Lambda function that renders the wallboard returns a preformatted HTML table. To display this on your secreen, you'll need to write a small piece of Javascript that embeds the wallboard table returned by API Gateway into a web page. Check out [this example page](https://github.com/aws-samples/aws-serverless-connect-wallboard/blob/master/wallboard-example.html) in this repo for a starting point. Note that you can use CSS to make additional changes to the appearance of the wallboard. 277 | 278 | If you'd prefer to render your wallboard using a front-end framework you can request that the API returns a JSON structure instead. 279 | ``` 280 | curl https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/wallboard?Wallboard=standard&json=true 281 | ``` 282 | The structure returned will look like this (non-JSON comments embedded for clarity): 283 | ```json 284 | { 285 | "Settings": { # Settings "global" to this wallboard - hints for how to render but not prescriptive 286 | "TextColour": "black", # Default text colour 287 | "BackgroundColour": "white", # Default background colour 288 | "FontSize": "15", # Default font size 289 | "Font": "sans-serif", # Default font type 290 | "AlertBackgroundColour": "red", # Colour for cells in "alert" 291 | "WarningBackgroundColour": "yellow", # Colour for cells in "warn" 292 | "AgentStateList": { # List of any custom agent states and associated colours 293 | "Lunch": "yellow" 294 | } 295 | }, 296 | "AgentStates": { # List of current agent names and states 297 | "Alice": "Lunch" 298 | }, 299 | "WallboardData": { # Data for each cell of the wallboard 300 | "R1C1": { # Row 1, Column 1 301 | "Format": { # Formatting hints that may override the "global" settings 302 | "BackgroundColour": "lightgreen", 303 | "TextSize": "20" 304 | }, 305 | "Text": "Agents Available" # Static text to display in the cell 306 | }, 307 | "R2C1": { 308 | "Format": { 309 | "Colour": "blue" 310 | }, 311 | "Metric": "AGENTS_AVAILABLE", # Name of the metric in this cell 312 | "Value": "0" # Value for this cell 313 | } 314 | } 315 | } 316 | ``` 317 | It is up to you to determine the appropriate way to parse the data for your purposes but the simplest way is that the metrics are contained within a JSON object called 'WallboardData' and each cell is labelled `RC`. The formatting hints (colours and threshold alerts) can be used by you or ignored as you see fit. 318 | ### Wallboard Tuning 319 | You may wish to tune specific events in the wallboard system. 320 | 321 | Historical metrics are retrieved every minute. This is triggered by CloudWatch Events and can be changed by modifying the `Connect-Wallboard-Historical-Collection` rule. You can also modify the [CloudFormation template](https://github.com/aws-samples/aws-serverless-connect-wallboard/blob/master/wallboard-cfn.yaml) before deployment. 322 | 323 | The wallboard configuration is checked every 300 seconds (five minutes) by default. This means that when you update an existing wallboard configuration it may take up to five minutes for the changes to be visible. This can be changed by adding an environment variable called `ConfigTimeout` for the `Connect-Wallboard-Render` and `Connect-Wallboard-Historical-Metrics` Lambda functions and making the value the number of seconds the function should wait before checking for any updated configuration. A small value will mean the functions read from the DynamoDB table more often. This may increase the cost of the solution due to increase database table activity. 324 | ### HTML Styles 325 | When rendered as a HTML table there are specific CSS stylesheet classes applied to each element. You can choose to override the default colours, fonts and formatting of the table if you wish. 326 | - The table will have a stylesheet class of `wallboard-`. For example, if your wallboard has a name of "primary" then the class will be `wallboard-primary`. 327 | - Each cell has a class name which is related to the row and column of that cell. The first cell on the first row of the wallboard will have a class of `R1C1` while the third cell on the fourth row will be `R4C3`. 328 | 329 | Because each cell is formatted with an inline style, you may have to use the `!important` CSS property to override that style. 330 | ## License Summary 331 | This sample code is made available under the MIT-0 license. See the LICENSE file. -------------------------------------------------------------------------------- /get-historical-metrics.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # 4 | # Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 7 | # software and associated documentation files (the "Software"), to deal in the Software 8 | # without restriction, including without limitation the rights to use, copy, modify, 9 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so. 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 13 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 14 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 15 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 16 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 17 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | # 19 | 20 | import boto3 21 | from boto3.dynamodb.conditions import Key,Attr 22 | import os 23 | import time 24 | import logging 25 | import datetime 26 | 27 | # 28 | # Things to configure 29 | # 30 | DDBTableName = os.environ.get('WallboardTable', 'ConnectWallboard') 31 | ConfigTimeout = int(os.environ.get('ConfigTimeout', 300)) # How long we wait before grabbing the config from the database 32 | ServiceLevelThreshold = 60 # See note in README.md 33 | MaxItemsPerAPICall = 100 # Maximum number of metrics returned from Connect 34 | Table = boto3.resource('dynamodb').Table(DDBTableName) 35 | 36 | logger = logging.getLogger() 37 | logger.setLevel(logging.INFO) 38 | 39 | # 40 | # Global state 41 | # 42 | LastRun = 0 43 | DataSources = {} 44 | Data = {} 45 | 46 | # 47 | # List of valid metrics we can retrieve 48 | # 49 | MetricUnitMapping = { 50 | 'CONTACTS_QUEUED': ['COUNT', 'SUM'], 51 | 'CONTACTS_HANDLED': ['COUNT', 'SUM'], 52 | 'CONTACTS_ABANDONED': ['COUNT', 'SUM'], 53 | 'CONTACTS_CONSULTED': ['COUNT', 'SUM'], 54 | 'CONTACTS_AGENT_HUNG_UP_FIRST': ['COUNT', 'SUM'], 55 | 'CONTACTS_HANDLED_INCOMING': ['COUNT', 'SUM'], 56 | 'CONTACTS_HANDLED_OUTBOUND': ['COUNT', 'SUM'], 57 | 'CONTACTS_HOLD_ABANDONS': ['COUNT', 'SUM'], 58 | 'CONTACTS_TRANSFERRED_IN': ['COUNT', 'SUM'], 59 | 'CONTACTS_TRANSFERRED_OUT': ['COUNT', 'SUM'], 60 | 'CONTACTS_TRANSFERRED_IN_FROM_QUEUE': ['COUNT', 'SUM'], 61 | 'CONTACTS_TRANSFERRED_OUT_FROM_QUEUE': ['COUNT', 'SUM'], 62 | 'CALLBACK_CONTACTS_HANDLED': ['COUNT', 'SUM'], 63 | 'API_CONTACTS_HANDLED': ['COUNT', 'SUM'], 64 | 'CONTACTS_MISSED': ['COUNT', 'SUM'], 65 | 'OCCUPANCY': ['PERCENT', 'AVG'], 66 | 'HANDLE_TIME': ['SECONDS', 'AVG'], 67 | 'AFTER_CONTACT_WORK_TIME': ['SECONDS', 'AVG'], 68 | 'QUEUED_TIME': ['SECONDS', 'MAX'], 69 | 'ABANDON_TIME': ['COUNT', 'SUM'], 70 | 'QUEUE_ANSWER_TIME': ['SECONDS', 'AVG'], 71 | 'HOLD_TIME': ['SECONDS', 'AVG'], 72 | 'INTERACTION_TIME': ['SECONDS', 'AVG'], 73 | 'INTERACTION_AND_HOLD_TIME': ['SECONDS', 'AVG'], 74 | 'SERVICE_LEVEL': ['PERCENT', 'AVG'] 75 | } 76 | 77 | def ProcessChunks(List, Size): 78 | return (List[Pos:Pos+Size] for Pos in range(0, len(List), Size)) 79 | 80 | def GetConfiguration(): 81 | global LastRun,ConfigTimeout,DDBTableName,Table,DataSources,UnitMapping 82 | 83 | # 84 | # We only want to retrieve the configuration for the wallboard if we haven't 85 | # retrieved it recently or it hasn't previously been loaded. 86 | # 87 | logging.info(f'Last run at {LastRun}, timeout is {ConfigTimeout}, now is {time.time()}') 88 | 89 | if time.time() < LastRun+ConfigTimeout: 90 | logging.info(' Within timeout period - no config refresh') 91 | return 92 | LastRun = time.time() 93 | 94 | # 95 | # All relevant wallboard information (how it is to be formatted, threshold 96 | # details, etc.) all have a primary partition key of the name of the 97 | # wallboard. 98 | # 99 | Expression = Attr('RecordType').begins_with('DataSource') 100 | try: 101 | Response = Table.scan(FilterExpression=Expression) 102 | ConfigList = Response 103 | except Exception as e: 104 | logging.error(f'DynamoDB error: {e}') 105 | return False 106 | 107 | if len(Response['Items']) == 0: 108 | logging.error('Did not get any data sources') 109 | return 110 | 111 | while 'LastEvaluatedKey' in Response: 112 | try: 113 | Response = Table.scan(ExclusiveStartKey=Response['LastEvaluatedKey']) 114 | ConfigList.update(Response) 115 | except Exception as e: 116 | logging.error(f'DynamoDB error: {e}') 117 | break 118 | 119 | DataSources = {} 120 | for Item in ConfigList['Items']: 121 | if 'Name' not in Item: 122 | logging.warning(f'Data source reference not set for {Item["RecordType"]} - ignored') 123 | continue 124 | 125 | Metric = Item['Reference'].split(':')[2] 126 | if Metric not in MetricUnitMapping: continue # Ignore non-historical metrics 127 | DataSources[Item['Name']] = Item['Reference'] 128 | 129 | return 130 | 131 | def StoreMetric(ConnectARN, QueueARN, MetricName, Value): 132 | global DataSources,Data,logging 133 | 134 | SourceString = f'{ConnectARN}:{QueueARN}:{MetricName}' 135 | 136 | for Source in DataSources: 137 | if DataSources[Source] == SourceString: 138 | Data[Source] = str(int(Value)) 139 | logging.info(f'Storing {Data[Source]} in {Source}') 140 | return 141 | 142 | logging.warning(f'Could not find {SourceString} in DataSources') 143 | 144 | def GetHistoricalData(): 145 | global logging,LastRealtimeRun,Data,DataSources,MetricUnitMapping,Data 146 | 147 | Connect = boto3.client('connect') 148 | 149 | # 150 | # Build a list of information we need from the API. 151 | # 152 | ConnectList = {} 153 | for Item in DataSources: 154 | if Item not in Data: Data[Item] = '0' 155 | 156 | (ConnectARN,QueueARN,Metric) = DataSources[Item].split(':') 157 | 158 | if ConnectARN not in ConnectList: ConnectList[ConnectARN] = {} 159 | if QueueARN not in ConnectList[ConnectARN]: ConnectList[ConnectARN][QueueARN] = [] 160 | 161 | if Metric == 'SERVICE_LEVEL': 162 | ConnectList[ConnectARN][QueueARN].append({'Name':Metric,'Unit':MetricUnitMapping[Metric][0],'Statistic':MetricUnitMapping[Metric][1],'Threshold':{'Comparison':'LT','ThresholdValue':ServiceLevelThreshold}}) 163 | else: 164 | ConnectList[ConnectARN][QueueARN].append({'Name':Metric,'Unit':MetricUnitMapping[Metric][0],'Statistic':MetricUnitMapping[Metric][1]}) 165 | 166 | FiveMinuteMark = datetime.datetime.now().minute-datetime.datetime.now().minute%5 167 | 168 | # 169 | # Now call the API for each Connect instance we're interested in. 170 | # 171 | for Instance in ConnectList: 172 | logging.info(f'Retrieving historical data from {Instance}') 173 | 174 | MetricList = [] 175 | for Queue in ConnectList[Instance]: 176 | MetricList += ConnectList[Instance][Queue] 177 | logging.info(f' Metrics: {MetricList}') 178 | 179 | ChunkSize = int(MaxItemsPerAPICall/(len(MetricList)*len(ConnectList[Instance]))) 180 | 181 | for QueueList in ProcessChunks(list(ConnectList[Instance].keys()), ChunkSize): 182 | logging.info(f' Queues: {QueueList}') 183 | try: 184 | Response = Connect.get_metric_data( 185 | InstanceId=Instance, 186 | StartTime=datetime.datetime.now().replace(hour=0, minute=0, second=0), 187 | EndTime=datetime.datetime.now().replace(minute=FiveMinuteMark, second=0), 188 | Groupings=['QUEUE'], 189 | Filters={'Queues':QueueList}, 190 | HistoricalMetrics=MetricList) 191 | except Exception as e: 192 | logging.error(f'Failed to get historical data: {e}') 193 | continue 194 | 195 | if 'MetricResults' not in Response: continue 196 | for Collection in Response['MetricResults']: 197 | QueueARN = Collection['Dimensions']['Queue']['Id'] 198 | 199 | for Metric in Collection['Collections']: 200 | MetricName = Metric['Metric']['Name'] 201 | MetricValue = Metric['Value'] 202 | StoreMetric(Instance, QueueARN, MetricName, MetricValue) 203 | 204 | def WriteData(): 205 | global Table,Data 206 | 207 | for Item in Data: 208 | DDBOutput = {} 209 | DDBOutput['Identifier'] = 'Data' 210 | DDBOutput['RecordType'] = Item 211 | DDBOutput['Value'] = Data[Item] 212 | 213 | try: 214 | Table.put_item(TableName=DDBTableName, Item=DDBOutput) 215 | except Exception as e: 216 | logging.error(f'DynamoDB put error: {e}') 217 | 218 | def lambda_handler(event, context): 219 | GetConfiguration() 220 | GetHistoricalData() 221 | WriteData() -------------------------------------------------------------------------------- /process-agent-event.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # 4 | # Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 7 | # software and associated documentation files (the "Software"), to deal in the Software 8 | # without restriction, including without limitation the rights to use, copy, modify, 9 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so. 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 13 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 14 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 15 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 16 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 17 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | # 19 | 20 | import boto3 21 | from boto3.dynamodb.conditions import Attr 22 | import base64 23 | import json 24 | import os 25 | import logging 26 | 27 | DDBTableName = os.environ.get('WallboardTable', 'ConnectWallboard') 28 | Table = boto3.resource('dynamodb').Table(DDBTableName) 29 | 30 | logger = logging.getLogger() 31 | logger.setLevel(logging.INFO) 32 | 33 | def SaveStateToDDB(Username, FullAgentName, AgentARN, State): 34 | global Table 35 | 36 | Data = {} 37 | Data['Identifier'] = 'Data' 38 | Data['RecordType'] = Username 39 | Data['Value'] = State 40 | Data['AgentARN'] = AgentARN 41 | Data['FullAgentName'] = FullAgentName 42 | 43 | try: 44 | Table.put_item(TableName=DDBTableName, Item=Data) 45 | except Exception as e: 46 | logger.error(f'DDB put error: {e}') 47 | 48 | def SaveStateUsingARN(AgentARN, State): 49 | global Table 50 | 51 | try: 52 | # Scan the table looking for the agent ARN 53 | Expression = Attr('AgentARN').eq(AgentARN) 54 | Response = Table.scan(FilterExpression=Expression) 55 | except Exception as e: 56 | logger.error(f'DDB scan error: {e}') 57 | return 58 | 59 | if len(Response['Items']) > 0: 60 | logger.info(f'AgentARN: {AgentARN} = {Response["Items"][0]["RecordType"]}') 61 | SaveStateToDDB(Response['Items'][0]['RecordType'], Response['Items'][0]['FullAgentName'], AgentARN, State) 62 | 63 | def lambda_handler(event, context): 64 | for RawPayload in event['Records']: 65 | AgentEvent = json.loads(base64.b64decode(RawPayload['kinesis']['data'])) 66 | EventType = AgentEvent['EventType'] 67 | AgentARN = AgentEvent['AgentARN'] 68 | logger.info('Event type: {EventType} AgentARN: {AgentARN}') 69 | 70 | if EventType == 'LOGIN': # We don't really need to do this but just in case... 71 | SaveStateUsingARN(AgentARN, 'Login') 72 | continue 73 | if EventType == 'LOGOUT': 74 | SaveStateUsingARN(AgentARN, 'Logout') 75 | continue 76 | if EventType == 'STATE_CHANGE': 77 | State = AgentEvent['CurrentAgentSnapshot']['AgentStatus']['Name'] 78 | AgentName = f'{AgentEvent["CurrentAgentSnapshot"]["Configuration"]["FirstName"]} {AgentEvent["CurrentAgentSnapshot"]["Configuration"]["LastName"]}' 79 | Username = AgentEvent['CurrentAgentSnapshot']['Configuration']['Username'] 80 | 81 | if State == 'Available': 82 | Contacts = AgentEvent["CurrentAgentSnapshot"]["Contacts"] 83 | 84 | if Contacts: 85 | for Contact in Contacts: 86 | ContactState = Contact['State'] 87 | if ContactState == 'CONNECTED': 88 | State = 'On Contact' 89 | elif ContactState == 'CONNECTING': 90 | State = 'On Contact' 91 | else: 92 | State = 'After Call Work' 93 | else: 94 | State = 'Available' 95 | 96 | logger.info(f'Agent: {AgentName}+ ({Username}) State: {State}') 97 | if len(AgentName) == 1: logger.warning('Expected first and last name of agent but did not get it in the event.') 98 | 99 | SaveStateToDDB(Username, AgentName, AgentARN, State) 100 | continue 101 | if EventType == 'HEART_BEAT': 102 | # Not sure what to do here yet 103 | continue 104 | 105 | logger.warning(f'Unknown event type: {EventType}') -------------------------------------------------------------------------------- /render-wallboard.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # 4 | # Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 7 | # software and associated documentation files (the "Software"), to deal in the Software 8 | # without restriction, including without limitation the rights to use, copy, modify, 9 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so. 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 13 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 14 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 15 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 16 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 17 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | # 19 | 20 | import boto3 21 | from boto3.dynamodb.conditions import Key,Attr 22 | import os 23 | import time 24 | import datetime 25 | import logging 26 | import string 27 | import re 28 | import json 29 | 30 | # 31 | # Things to configure 32 | # 33 | DDBTableName = os.environ.get('WallboardTable', 'ConnectWallboard') 34 | ConfigTimeout = int(os.environ.get('ConfigTimeout', 300)) # How long we wait before grabbing the config from the database 35 | RealtimeTimeout = 5 # How long before in between polling the real-time API 36 | Table = boto3.resource('dynamodb').Table(DDBTableName) 37 | 38 | logger = logging.getLogger() 39 | logger.setLevel(logging.INFO) 40 | 41 | # 42 | # Sane defaults for new wallboards in case specific settings aren't given 43 | # 44 | DefaultSettings = { 45 | 'AlertBackgroundColour': 'red', 46 | 'WarningBackgroundColour': 'yellow', 47 | 'TextColour': 'black', 48 | 'Font': 'sans-serif', 49 | 'BackgroundColour': 'lightgrey' 50 | } 51 | 52 | # 53 | # Global state 54 | # 55 | LastRun = 0 56 | LastRealtimeRun = 0 57 | Settings = {} 58 | Cells = {} 59 | Thresholds = {} 60 | AgentStates = {} 61 | Data = {} 62 | Calculations = {} 63 | DataSources = {} 64 | NextAgent = 0 65 | SortedAgentList = [] 66 | FullAgentNames = {} 67 | 68 | # 69 | # List of valid metrics we can retrieve 70 | # 71 | MetricUnitMapping = { 72 | 'AGENTS_AVAILABLE': 'COUNT', 73 | 'AGENTS_ONLINE': 'COUNT', 74 | 'AGENTS_ON_CALL': 'COUNT', 75 | 'AGENTS_STAFFED': 'COUNT', 76 | 'AGENTS_AFTER_CONTACT_WORK': 'COUNT', 77 | 'AGENTS_NON_PRODUCTIVE': 'COUNT', 78 | 'AGENTS_ERROR': 'COUNT', 79 | 'CONTACTS_IN_QUEUE': 'COUNT', 80 | 'OLDEST_CONTACT_AGE': 'SECONDS', 81 | 'CONTACTS_SCHEDULED': 'COUNT' 82 | } 83 | 84 | # 85 | # List of functions that can be used in calculations - probably not complete 86 | # so should be added to as necessary 87 | # 88 | FunctionList = ['round', 'int', 'float', 'min', 'max', 'sum', 'ord', 'pow'] 89 | 90 | def GetConfiguration(WallboardName): 91 | global LastRun,ConfigTimeout,DDBTableName,Table,Settings,Cells,Thresholds,AgentStates,Calculations,DataSources 92 | 93 | # 94 | # We only want to retrieve the configuration for the wallboard if we haven't 95 | # retrieved it recently or it hasn't previously been loaded. 96 | # 97 | GetConfig = False 98 | if WallboardName not in Settings: 99 | LastRun = time.time() 100 | GetConfig = True 101 | logger.info(f'No config loaded for {WallboardName} retrieving') 102 | else: 103 | logger.info(f'Last run at {LastRun}, timeout is {ConfigTimeout}, now is {time.time()}') 104 | 105 | if time.time() > LastRun+ConfigTimeout: 106 | LastRun = time.time() 107 | GetConfig = True 108 | logger.info(' Wallboard config needs refreshing') 109 | else: 110 | logger.info(' Within timeout period - no config refresh') 111 | 112 | if not GetConfig: return True 113 | 114 | # 115 | # All relevant wallboard information (how it is to be formatted, threshold 116 | # details, etc.) all have a primary partition key of the name of the 117 | # wallboard. 118 | # 119 | try: 120 | Response = Table.query(KeyConditionExpression=Key('Identifier').eq(WallboardName)) 121 | ConfigList = Response 122 | except Exception as e: 123 | logger.error(f'DynamoDB error: {e}') 124 | return False 125 | 126 | if len(Response['Items']) == 0: 127 | logger.error(f'Did not get any configuration for wallboard {WallboardName}') 128 | return False 129 | 130 | while 'LastEvaluatedKey' in Response: 131 | try: 132 | Response = Table.query(ExclusiveStartKey=Response['LastEvaluatedKey']) 133 | ConfigList.update(Response) 134 | except Exception as e: 135 | logger.error(f'DynamoDB error: {e}') 136 | break 137 | 138 | LocalSettings = DefaultSettings.copy() 139 | LocalThresholds = {} 140 | LocalCells = {} 141 | LocalAgentStates = {} 142 | LocalCalculations = {} 143 | LocalDataSources = {} 144 | for Item in ConfigList['Items']: 145 | if Item['RecordType'] == 'Settings': 146 | for Config in Item: 147 | LocalSettings[Config] = Item[Config] 148 | elif Item['RecordType'][:11] == 'Calculation': 149 | if 'Formula' not in Item: 150 | logger.warning(f'Formula not set for {Item["RecordType"]} in wallboard {WallboardName} - ignored') 151 | continue 152 | LocalCalculations[Item['Name']] = Item['Formula'] 153 | elif Item['RecordType'][:4] == 'Cell': 154 | if 'Address' not in Item: 155 | logger.warning(f'Cell address not set for {Item["RecordType"]} in wallboard {WallboardName} - ignored') 156 | continue 157 | LocalCells[Item['Address']] = Item 158 | elif Item['RecordType'][:9] == 'Threshold': 159 | if 'Name' not in Item: 160 | logger.warning(f'Threshold name not set for {Item["RecordType"]} in wallboard {WallboardName} - ignored') 161 | continue 162 | LocalThresholds[Item['Name']] = Item 163 | elif Item['RecordType'][:10] == 'AgentState': 164 | if 'StateName' not in Item: 165 | logger.warning(f'Agent state name not set for {Item["RecordType"]} in wallboard {WallboardName} - ignored') 166 | continue 167 | LocalAgentStates[Item['StateName']] = Item['BackgroundColour'] 168 | elif Item['RecordType'][:10] == 'DataSource': 169 | if 'Name' not in Item: 170 | logger.warning(f'Data source reference not set for {Item["RecordType"]} in wallboard {WallboardName} - ignored') 171 | continue 172 | 173 | Metric = Item['Reference'].split(':')[2] 174 | if Metric not in MetricUnitMapping: continue # Ignore non real-time metrics 175 | LocalDataSources[Item['Name']] = Item['Reference'] 176 | 177 | Settings[WallboardName] = LocalSettings 178 | Cells[WallboardName] = LocalCells 179 | Thresholds[WallboardName] = LocalThresholds 180 | AgentStates[WallboardName] = LocalAgentStates 181 | Calculations[WallboardName] = LocalCalculations 182 | DataSources[WallboardName] = LocalDataSources 183 | 184 | return True 185 | 186 | def GetData(): 187 | global Data,NextAgent,SortedAgentList,FullAgentNames 188 | 189 | SortedAgentList = [] 190 | NextAgent = 0 191 | 192 | # 193 | # All data retrieved from other sources is stored in the DDB table with 194 | # the primary partition key of "Data" and a primary sort key of the name 195 | # of the value that has been stored. 196 | # We could get back numerical data (stored as a string) or agent state 197 | # details. 198 | # 199 | try: 200 | Response = Table.query(KeyConditionExpression=Key('Identifier').eq('Data')) 201 | AllData = Response 202 | except Exception as e: 203 | logger.error(f'DynamoDB error: {e}') 204 | return 205 | 206 | if len(Response['Items']) == 0: 207 | logger.error('Did not get any data from DynamoDB') 208 | return 209 | 210 | while 'LastEvaluatedKey' in Response: 211 | try: 212 | Response = Table.query(ExclusiveStartKey=Response['LastEvaluatedKey']) 213 | AllData.update(Response) 214 | except Exception as e: 215 | logger.error(f'DynamoDB error: {e}') 216 | break 217 | 218 | for Item in AllData['Items']: 219 | Data[Item['RecordType']] = Item['Value'] 220 | if 'AgentARN' in Item: 221 | SortedAgentList.append(Item['RecordType']) 222 | if 'FullAgentName' in Item: 223 | FullAgentNames[Item['RecordType']] = Item['FullAgentName'] 224 | 225 | # 226 | # We want the agents in alphabetical order 227 | # 228 | SortedAgentList.sort() 229 | 230 | def StoreMetric(ConnectARN, QueueARN, MetricName, Value): 231 | global DataSources,Data 232 | 233 | SourceString = f'{ConnectARN}:{QueueARN}:{MetricName}' 234 | 235 | for Wallboard in DataSources: 236 | for Source in DataSources[Wallboard]: 237 | if DataSources[Wallboard][Source] == SourceString: 238 | Data[Source] = str(int(Value)) 239 | logger.info(f'Storing {Data[Source]} in {Source}') 240 | return 241 | 242 | logger.warning(f'Could not find {SourceString} in DataSources') 243 | 244 | def GetRealtimeData(): 245 | global LastRealtimeRun,Data,DataSources,MetricUnitMapping 246 | 247 | # 248 | # We only want to poll the real-time API every so often. 249 | # 250 | logger.info(f'Last real-time poll at {LastRealtimeRun}, timeout is {RealtimeTimeout}, now is {time.time()}') 251 | 252 | if time.time() < LastRealtimeRun+RealtimeTimeout: return 253 | LastRealtimeRun = time.time() 254 | 255 | Connect = boto3.client('connect') 256 | 257 | # 258 | # Even though data sources are defined per wallboard we will always retrieve 259 | # all of them each time as they may be cross-referenced on other wallboards. 260 | # 261 | # First build a list of information we need from the API. 262 | # 263 | ConnectList = {} 264 | for WallboardName in DataSources: 265 | for Item in DataSources[WallboardName]: 266 | if Item not in Data: Data[Item] = '0' 267 | 268 | (ConnectARN,QueueARN,Metric) = DataSources[WallboardName][Item].split(':') 269 | 270 | if ConnectARN not in ConnectList: ConnectList[ConnectARN] = {} 271 | if QueueARN not in ConnectList[ConnectARN]: ConnectList[ConnectARN][QueueARN] = [] 272 | ConnectList[ConnectARN][QueueARN].append({'Name':Metric, 'Unit':MetricUnitMapping[Metric]}) 273 | 274 | # 275 | # Now call the API for each Connect instance we're interested in. 276 | # 277 | for Instance in ConnectList: 278 | logger.info(f'Retrieving real-time data from {Instance}') 279 | 280 | MetricList = [] 281 | for Queue in ConnectList[Instance]: 282 | MetricList += ConnectList[Instance][Queue] 283 | 284 | try: 285 | Response = Connect.get_current_metric_data( 286 | InstanceId=Instance, 287 | Groupings=['QUEUE'], 288 | Filters={'Queues':list(ConnectList[Instance].keys())}, 289 | CurrentMetrics=MetricList) 290 | except Exception as e: 291 | logger.error(f'Failed to get real-time data: {e}') 292 | continue 293 | 294 | if 'MetricResults' not in Response: continue 295 | for Collection in Response['MetricResults']: 296 | QueueARN = Collection['Dimensions']['Queue']['Id'] 297 | 298 | for Metric in Collection['Collections']: 299 | MetricName = Metric['Metric']['Name'] 300 | MetricValue = Metric['Value'] 301 | StoreMetric(Instance, QueueARN, MetricName, MetricValue) 302 | 303 | def DoCalculation(WallboardName, Reference): 304 | global Data,Calculations,FunctionList 305 | 306 | Result = '0' # All values are stored as strings when they come out of DDB 307 | 308 | # 309 | # Split the calculation based on mathemetical operators 310 | # 311 | CalcArray = re.split('(\+|\*|\-|\/|\(|\)|,)', Calculations[WallboardName][Reference]) 312 | 313 | # Substitute in the values for the labels in the calculation 314 | # 315 | Index = 0 316 | for Index in range(0, len(CalcArray)): 317 | if CalcArray[Index] in string.punctuation: continue 318 | if CalcArray[Index] in FunctionList: continue 319 | if CalcArray[Index][0] in string.digits: continue 320 | 321 | if CalcArray[Index] in Data: 322 | CalcArray[Index] = Data[CalcArray[Index]] 323 | else: 324 | logger.warning(f'Calc: Could not find reference {CalcArray[Index]}') 325 | CalcArray[Index] = '0' 326 | 327 | CalcString = ''.join(CalcArray) 328 | logger.info(f'Calculation for {Reference}: {Calculations[WallboardName][Reference]} -> {CalcString}') 329 | 330 | try: 331 | Result = str(eval(CalcString)) 332 | except Exception as e: 333 | logger.error(f'Could not eval {Reference}: {Calculations[WallboardName][Reference]} -> {CalcString} : {e}') 334 | 335 | return Result 336 | 337 | def CheckThreshold(WallboardName, ThresholdReference): 338 | global Settings,Data,Thresholds,Calculations 339 | 340 | # 341 | # For the given data reference, check for any threshold details and then 342 | # return the right colour (which will be used for the cell background when 343 | # displayed). We have warning thresholds (above and below) and error 344 | # thresholds (above and below). 345 | # 346 | Colour = '' 347 | ThresholdLevel = 'Normal' # Additional flag for JSON data return 348 | 349 | if ThresholdReference not in Thresholds[WallboardName]: 350 | logger.warning(f'Threshold reference {ThresholdReference} does not exist for wallboard {WallboardName}') 351 | return Colour, ThresholdLevel 352 | 353 | Threshold = Thresholds[WallboardName][ThresholdReference] 354 | if 'Reference' not in Threshold: 355 | logger.warning(f'No data reference present in threshold {ThresholdReference} for wallboard {WallboardName}') 356 | return Colour, ThresholdLevel 357 | 358 | if Threshold['Reference'] not in Data: 359 | if Threshold['Reference'] in Calculations: 360 | Data[Threshold['Reference']] = DoCalculation(WallboardName, Threshold['Reference']) 361 | else: 362 | logger.warning(f'Data reference {Threshold["Reference"]} in threshold {ThresholdReference} does not exist for wallboard {WallboardName}') 363 | return Colour, ThresholdLevel 364 | 365 | if 'WarnBelow' in Threshold: 366 | if int(Data[Threshold['Reference']]) < int(Threshold['WarnBelow']): 367 | Colour = Settings[WallboardName]['WarningBackgroundColour'] 368 | ThresholdLevel = 'Warning' 369 | if 'AlertBelow' in Threshold: 370 | if int(Data[Threshold['Reference']]) < int(Threshold['AlertBelow']): 371 | Colour = Settings[WallboardName]['AlertBackgroundColour'] 372 | ThresholdLevel = 'Alert' 373 | if 'WarnAbove' in Threshold: 374 | if int(Data[Threshold['Reference']]) > int(Threshold['WarnAbove']): 375 | Colour = Settings[WallboardName]['WarningBackgroundColour'] 376 | ThresholdLevel = 'Warning' 377 | if 'AlertAbove' in Threshold: 378 | if int(Data[Threshold['Reference']]) > int(Threshold['AlertAbove']): 379 | Colour = Settings[WallboardName]['AlertBackgroundColour'] 380 | ThresholdLevel = 'Alert' 381 | 382 | return Colour, ThresholdLevel 383 | 384 | def GetNextAgent(GetActive, JSONFlag=False): 385 | global SortedAgentList,NextAgent,FullAgentNames 386 | 387 | # 388 | # When we need to display a list of all agents currently active, this 389 | # function returns the names one-by-one so that the caller can fill in the 390 | # cells in the wallboard table. 391 | # 392 | AgentName = '' 393 | HTML = '' 394 | JSON = {} 395 | 396 | if NextAgent >= len(SortedAgentList): # No more agents to list 397 | if JSONFlag: 398 | return JSON, '' 399 | else: 400 | return HTML, '' 401 | 402 | if not GetActive: # Return the next agent whether active in the system or not 403 | AgentName = SortedAgentList[NextAgent] 404 | NextAgent += 1 405 | else: # Return the next active agent 406 | while NextAgent < len(SortedAgentList): 407 | AgentState = Data[SortedAgentList[NextAgent]] 408 | if len(AgentState) == 0 or AgentState == 'Logout': 409 | NextAgent += 1 410 | continue 411 | 412 | AgentName = SortedAgentList[NextAgent] 413 | NextAgent += 1 414 | break 415 | 416 | if len(AgentName) == 0: # No agent found 417 | if JSONFlag: 418 | return JSON, '' 419 | else: 420 | return HTML, '' 421 | 422 | if JSONFlag: 423 | if AgentName in FullAgentNames: # Just in case we didn't find a full name for this agent 424 | JSON['FullAgentName'] = FullAgentNames[AgentName] 425 | JSON['AgentState'] = Data[AgentName] 426 | 427 | return JSON, AgentName 428 | else: 429 | if AgentName in FullAgentNames: # Just in case we didn't find a full name for this agent 430 | HTML += f'
{FullAgentNames[AgentName]}
' 431 | HTML += f'
{Data[AgentName]}
' 432 | 433 | return HTML, Data[AgentName] # Return the state so we can set the cell background colour 434 | 435 | def RenderCell(WallboardName, Row, Column): 436 | global AgentStates,Thresholds,Data,Calculations 437 | 438 | # 439 | # Given a particular cell, figure out the right colours and cell contents. 440 | # A cell may contain static text, a number or agent state derived directly 441 | # from the data read from the DDB table, or it may be a calculation we need 442 | # to perform. Also need to ensure that thresholds are checked for numerical 443 | # values where present. 444 | # 445 | Address = f'R{Row}C{Column}' 446 | HTML = '' 447 | AgentDetails = '' 448 | 449 | if Address not in Cells[WallboardName]: return HTML 450 | Cell = Cells[WallboardName][Address] 451 | LocalStates = AgentStates[WallboardName] 452 | 453 | Style = [] 454 | Style.append('border: 1px solid black; padding: 5px;') 455 | if 'TextColour' in Cell: Style.append(f'color: {Cell["TextColour"]};') 456 | if 'TextSize' in Cell: Style.append(f'font-size: {Cell["TextSize"]}px;') 457 | 458 | Background = '' 459 | if 'Reference' in Cell: 460 | State = '' 461 | if Cell['Reference'] in Calculations[WallboardName]: # We need to calculate this one 462 | Data[Cell['Reference']] = DoCalculation(WallboardName, Cell['Reference']) 463 | elif Cell['Reference'].lower() in Data: # Data already exists 464 | State = Data[Cell['Reference']] 465 | elif Cell['Reference'] == '=allagents': # Any agent at all 466 | (AgentDetails, State) = GetNextAgent(False) 467 | elif Cell['Reference'] == '=activeagents': # Active agents only 468 | (AgentDetails, State) = GetNextAgent(True) 469 | 470 | if len(State) > 0: 471 | State = State.lower() 472 | if State in LocalStates: 473 | Background = LocalStates[State] 474 | 475 | if 'ThresholdReference' in Cell: 476 | (NewBackground,Level) = CheckThreshold(WallboardName, Cell['ThresholdReference']) 477 | if len(NewBackground) > 0: Background = NewBackground 478 | 479 | if len(Background) == 0: 480 | if 'BackgroundColour' in Cell: Background = Cell['BackgroundColour'] 481 | if len(Background) > 0: Style.append(f'background: {Background};') 482 | 483 | Tag = f'R{Row}C{Column}' 484 | HTML += f' 0: HTML += f' style="{" ".join(Style)}"' 488 | HTML += '>' 489 | 490 | if 'Text' in Cell: HTML += f'
{Cell["Text"]}
' 491 | if 'Reference' in Cell: 492 | if Cell['Reference'] in Data: 493 | RawData = Data[Cell['Reference']] 494 | if 'Format' in Cell: 495 | if Cell['Format'] == 'Time': 496 | try: 497 | FinalData = str(datetime.timedelta(0, RawData)) 498 | except: 499 | logger.error(f'Could not format {RawData} in cell {Address} as time - ignoring') 500 | FinalData = RawData 501 | else: 502 | FinalData = RawData 503 | logger.warning(f'Format {Cell["Format"]} in cell {Address} for wallboard {WallboardName} is not supported') 504 | else: 505 | FinalData = RawData 506 | HTML += f'
{FinalData}
' 507 | elif Cell['Reference'] == '=allagents' or Cell['Reference'] == '=activeagents': 508 | HTML += AgentDetails 509 | else: 510 | logger.warning(f'Data reference {Cell["Reference"]} in cell {Address} does not exist for wallboard {WallboardName}') 511 | 512 | HTML += '' 513 | return HTML 514 | 515 | def RenderHTML(WallboardName): 516 | global Settings 517 | 518 | # 519 | # Build the containing table for the wallboard and then render each cell 520 | # according to the wallboard configuration. 521 | # 522 | LocalSettings = Settings[WallboardName] 523 | HTML = '' 524 | 525 | HTML += f'\n' 532 | 533 | for Row in range(1, int(LocalSettings['Rows'])+1): 534 | HTML += ' ' 535 | for Column in range(1, int(LocalSettings['Columns'])+1): 536 | HTML += RenderCell(WallboardName, Row, Column) 537 | HTML += '\n' 538 | 539 | HTML += '
\n' 540 | 541 | return HTML 542 | 543 | def GetRawCellData(WallboardName, Row, Column): 544 | global AgentStates,Thresholds,Data,Calculations 545 | 546 | # 547 | # As with the HTML render, Given a particular cell, get the data from the 548 | # appropriate source but return it as a dictionary. 549 | # 550 | Address = f'R{Row}C{Column}' 551 | JSON = {} 552 | 553 | if Address not in Cells[WallboardName]: return JSON 554 | Cell = Cells[WallboardName][Address] 555 | 556 | # 557 | # Agent state is sent back in a different place for a JSON return so we 558 | # don't do that here. 559 | # 560 | if 'Reference' in Cell: 561 | if Cell['Reference'] == '=allagents' or Cell['Reference'] == '=activeagents': 562 | return JSON 563 | 564 | # 565 | # As with the whole table the front-end process can ignore the formatting "hints". 566 | # 567 | Format = {} 568 | if 'BackgroundColour' in Cell: Format['BackgroundColour'] = Cell['BackgroundColour'] 569 | if 'TextColour' in Cell: Format['Colour'] = Cell['TextColour'] 570 | if 'TextSize' in Cell: Format['TextSize'] = Cell['TextSize'] 571 | 572 | if 'Reference' in Cell: 573 | if Cell['Reference'] in Calculations[WallboardName]: # We need to calculate this one 574 | Data[Cell['Reference']] = DoCalculation(WallboardName, Cell['Reference']) 575 | 576 | if 'ThresholdReference' in Cell: 577 | (Background,Level) = CheckThreshold(WallboardName, Cell['ThresholdReference']) 578 | if len(Background) > 0: Format['BackgroundColour'] = Background 579 | JSON['Threshold'] = Level 580 | 581 | if 'Rows' in Cell: Format['RowSpan'] = Cell['Rows'] 582 | if 'Columns' in Cell: Format['ColSpan'] = Cell['Columns'] 583 | 584 | JSON['Format'] = Format 585 | 586 | if 'Text' in Cell: JSON['Text'] = Cell['Text'] 587 | if 'Reference' in Cell: 588 | JSON['Metric'] = Cell['Reference'] 589 | if Cell['Reference'] in Data: 590 | JSON['Value'] = Data[Cell['Reference']] 591 | 592 | return JSON 593 | 594 | def RenderJSON(WallboardName): 595 | global Settings 596 | 597 | # 598 | # Build a dictionary with all of the data in it - basically the same as 599 | # the HTML table but in JSON so that the front end can render the data 600 | # however it likes. 601 | # 602 | LocalSettings = Settings[WallboardName] 603 | JSON = {} 604 | 605 | # 606 | # The settings provided are for appearance only so the front end can 607 | # ignore these and render the data in whatever format is appropriate. 608 | # 609 | JSON['Settings'] = {} 610 | if 'TextColour' in LocalSettings: JSON['Settings']['TextColour'] = LocalSettings['TextColour'] 611 | if 'BackgroundColour' in LocalSettings: JSON['Settings']['BackgroundColour'] = LocalSettings['BackgroundColour'] 612 | if 'TextSize' in LocalSettings: JSON['Settings']['FontSize'] = LocalSettings['TextSize'] 613 | if 'Font' in LocalSettings: JSON['Settings']['Font'] = LocalSettings['Font'] 614 | if 'AlertBackgroundColour' in LocalSettings: JSON['Settings']['AlertBackgroundColour'] = LocalSettings['AlertBackgroundColour'] 615 | if 'WarningBackgroundColour' in LocalSettings: JSON['Settings']['WarningBackgroundColour'] = LocalSettings['WarningBackgroundColour'] 616 | JSON['Settings']['AgentStateList'] = AgentStates[WallboardName] 617 | 618 | # 619 | # Get all the agent states. 620 | # 621 | JSON['AgentStates'] = {} 622 | (AgentState,AgentName) = GetNextAgent(False, JSONFlag=True) 623 | while len(AgentName) > 0: 624 | JSON['AgentStates'][AgentName] = AgentState 625 | (AgentState,AgentName) = GetNextAgent(False, JSONFlag=True) 626 | 627 | # 628 | # Now the rest of the data for this wallboard. 629 | # 630 | JSON['WallboardData'] = {} 631 | for Row in range(1, int(LocalSettings['Rows'])+1): 632 | for Column in range(1, int(LocalSettings['Columns'])+1): 633 | CellData = GetRawCellData(WallboardName, Row, Column) 634 | if len(CellData): JSON['WallboardData'][f'R{Row}C{Column}'] = CellData 635 | 636 | return json.dumps(JSON) 637 | 638 | def lambda_handler(event, context): 639 | GetData() 640 | 641 | Response = {} 642 | Response['statusCode'] = 200 643 | Response['headers'] = {'Access-Control-Allow-Origin': '*'} 644 | 645 | if str(type(event['queryStringParameters'])).find('dict') == -1 or 'Wallboard' not in event['queryStringParameters']: 646 | Response['body'] = '
No wallboard name specified
' 647 | return Response 648 | 649 | WallboardName = event['queryStringParameters']['Wallboard'] 650 | if GetConfiguration(WallboardName): 651 | GetRealtimeData() 652 | 653 | JSONFlag = event['queryStringParameters'].get('json') 654 | if JSONFlag: 655 | OutputData = RenderJSON(WallboardName) 656 | else: 657 | OutputData = RenderHTML(WallboardName) 658 | else: 659 | OutputData = f'
Wallboard {WallboardName} not found
' 660 | 661 | Response['body'] = OutputData 662 | return Response -------------------------------------------------------------------------------- /wallboard-cfn.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | 3 | # 4 | # Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 7 | # software and associated documentation files (the "Software"), to deal in the Software 8 | # without restriction, including without limitation the rights to use, copy, modify, 9 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so. 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 13 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 14 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 15 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 16 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 17 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | # 19 | 20 | Description: > 21 | Create the Lambda functions and other components required for the Connect Wallboard. 22 | 23 | Parameters: 24 | DDBTable: 25 | Type: String 26 | Description: DynamoDB table to create 27 | Default: ConnectWallboard 28 | KinesisAgentStream: 29 | Type: String 30 | Description: Kinesis agent event stream ARN - required to set appropriate Lambda trigger 31 | 32 | Outputs: 33 | APIGatewayURL: 34 | Value: !Join ["", ["https://", !Ref "APIGateway", ".execute-api.", !Ref "AWS::Region", ".amazonaws.com/", !Ref "APIGatewayStage", "/wallboard/"]] 35 | DynamoDBTableName: 36 | Value: !Ref DDBTable 37 | 38 | Resources: 39 | DynamoDBTable: 40 | Type: AWS::DynamoDB::Table 41 | Properties: 42 | TableName: !Ref DDBTable 43 | AttributeDefinitions: 44 | - AttributeName: "Identifier" 45 | AttributeType: "S" 46 | - AttributeName: "RecordType" 47 | AttributeType: "S" 48 | KeySchema: 49 | - AttributeName: "Identifier" 50 | KeyType: HASH 51 | - AttributeName: "RecordType" 52 | KeyType: RANGE 53 | ProvisionedThroughput: 54 | ReadCapacityUnits: 10 55 | WriteCapacityUnits: 10 56 | 57 | LambdaRenderRole: 58 | Type: AWS::IAM::Role 59 | Properties: 60 | AssumeRolePolicyDocument: 61 | Version: 2012-10-17 62 | Statement: 63 | - Effect: Allow 64 | Principal: 65 | Service: 66 | - lambda.amazonaws.com 67 | Action: 68 | - sts:AssumeRole 69 | ManagedPolicyArns: 70 | - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 71 | Policies: 72 | - PolicyName: DynamoDBPolicy 73 | PolicyDocument: 74 | Version: 2012-10-17 75 | Statement: 76 | - Action: 77 | - dynamodb:Query 78 | Effect: Allow 79 | Resource: !Sub "arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${DDBTable}" 80 | - PolicyName: ConnectPolicy 81 | PolicyDocument: 82 | Version: 2012-10-17 83 | Statement: 84 | - Action: 85 | - connect:GetCurrentMetricData 86 | Effect: Allow 87 | Resource: "*" 88 | 89 | LambdaHistoricalRole: 90 | Type: AWS::IAM::Role 91 | Properties: 92 | AssumeRolePolicyDocument: 93 | Version: 2012-10-17 94 | Statement: 95 | - Effect: Allow 96 | Principal: 97 | Service: 98 | - lambda.amazonaws.com 99 | Action: 100 | - sts:AssumeRole 101 | ManagedPolicyArns: 102 | - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 103 | Policies: 104 | - PolicyName: DynamoDBPolicy 105 | PolicyDocument: 106 | Version: 2012-10-17 107 | Statement: 108 | - Action: 109 | - dynamodb:Scan 110 | - dynamodb:PutItem 111 | Effect: Allow 112 | Resource: !Sub "arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${DDBTable}" 113 | - PolicyName: ConnectPolicy 114 | PolicyDocument: 115 | Version: 2012-10-17 116 | Statement: 117 | - Action: 118 | - connect:GetMetricData 119 | Effect: Allow 120 | Resource: "*" 121 | 122 | LambdaAgentRole: 123 | Type: AWS::IAM::Role 124 | Properties: 125 | AssumeRolePolicyDocument: 126 | Version: 2012-10-17 127 | Statement: 128 | - Effect: Allow 129 | Principal: 130 | Service: 131 | - lambda.amazonaws.com 132 | Action: 133 | - sts:AssumeRole 134 | ManagedPolicyArns: 135 | - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaKinesisExecutionRole" 136 | Policies: 137 | - PolicyName: DynamoDBPolicy 138 | PolicyDocument: 139 | Version: 2012-10-17 140 | Statement: 141 | - Action: 142 | - dynamodb:Scan 143 | - dynamodb:PutItem 144 | Effect: Allow 145 | Resource: !Sub "arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${DDBTable}" 146 | 147 | LambdaRenderPermission: 148 | Type: AWS::Lambda::Permission 149 | DependsOn: 150 | - APIGateway 151 | - LambdaRender 152 | Properties: 153 | Action: lambda:invokeFunction 154 | FunctionName: !Ref LambdaRender 155 | Principal: apigateway.amazonaws.com 156 | SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${APIGateway}/*" 157 | 158 | LambdaRender: 159 | Type: AWS::Lambda::Function 160 | DependsOn: LambdaRenderRole 161 | Properties: 162 | FunctionName: "Connect-Wallboard-Render" 163 | Code: 164 | S3Bucket: !Sub "xcafockufhle-${AWS::Region}" 165 | S3Key: render-wallboard.zip 166 | Description: "Connect wallboard rendering function" 167 | Handler: "render-wallboard.lambda_handler" 168 | Role: !GetAtt LambdaRenderRole.Arn 169 | Runtime: python3.13 170 | Timeout: 20 171 | Environment: 172 | Variables: 173 | WallboardTable: !Ref DDBTable 174 | 175 | LambdaAgentSourceMapping: 176 | Type: AWS::Lambda::EventSourceMapping 177 | Properties: 178 | EventSourceArn: !Ref KinesisAgentStream 179 | FunctionName: !Ref LambdaAgent 180 | StartingPosition: LATEST 181 | 182 | LambdaAgent: 183 | Type: AWS::Lambda::Function 184 | DependsOn: LambdaAgentRole 185 | Properties: 186 | FunctionName: "Connect-Wallboard-Agent-Events" 187 | Code: 188 | S3Bucket: !Sub "xcafockufhle-${AWS::Region}" 189 | S3Key: process-agent-event.zip 190 | Description: "Connect wallboard agent event processing" 191 | Handler: "process-agent-event.lambda_handler" 192 | Role: !GetAtt LambdaAgentRole.Arn 193 | Runtime: python3.13 194 | Timeout: 20 195 | Environment: 196 | Variables: 197 | WallboardTable: !Ref DDBTable 198 | 199 | LambdaHistoricalPermission: 200 | Type: AWS::Lambda::Permission 201 | DependsOn: LambdaHistorical 202 | Properties: 203 | Action: lambda:invokeFunction 204 | FunctionName: !Ref LambdaHistorical 205 | Principal: "events.amazonaws.com" 206 | SourceArn: !GetAtt HistoricalEvent.Arn 207 | 208 | LambdaHistorical: 209 | Type: AWS::Lambda::Function 210 | DependsOn: LambdaHistoricalRole 211 | Properties: 212 | FunctionName: "Connect-Wallboard-Historical-Metrics" 213 | Code: 214 | S3Bucket: !Sub "xcafockufhle-${AWS::Region}" 215 | S3Key: get-historical-metrics.zip 216 | Description: "Connect wallboard historical metrics data retrieval" 217 | Handler: "get-historical-metrics.lambda_handler" 218 | Role: !GetAtt LambdaHistoricalRole.Arn 219 | Runtime: python3.13 220 | Timeout: 20 221 | Environment: 222 | Variables: 223 | WallboardTable: !Ref DDBTable 224 | 225 | HistoricalEvent: 226 | Type: AWS::Events::Rule 227 | DependsOn: LambdaHistorical 228 | Properties: 229 | Name: "Connect-Wallboard-Historical-Collection" 230 | ScheduleExpression: "rate(1 minute)" 231 | State: ENABLED 232 | Targets: 233 | - Arn: !GetAtt LambdaHistorical.Arn 234 | Id: "HistoricalDataCollection" 235 | 236 | APIGateway: 237 | Type: AWS::ApiGateway::RestApi 238 | Properties: 239 | Name: "Connect Wallboard" 240 | FailOnWarnings: True 241 | 242 | APIGatewayStage: 243 | Type: AWS::ApiGateway::Stage 244 | Properties: 245 | StageName: "prod" 246 | RestApiId: !Ref APIGateway 247 | DeploymentId: !Ref APIGatewayDeployment 248 | MethodSettings: 249 | - ResourcePath: "/" 250 | HttpMethod: GET 251 | DataTraceEnabled: True 252 | 253 | APIGatewayDeployment: 254 | Type: AWS::ApiGateway::Deployment 255 | DependsOn: 256 | - APIGateway 257 | - APIGWMethod 258 | Properties: 259 | RestApiId: !Ref APIGateway 260 | 261 | APIGatewayResource: 262 | Type: AWS::ApiGateway::Resource 263 | Properties: 264 | RestApiId: !Ref APIGateway 265 | ParentId: !GetAtt APIGateway.RootResourceId 266 | PathPart: "wallboard" 267 | 268 | APIGWMethod: 269 | Type: AWS::ApiGateway::Method 270 | DependsOn: LambdaRender 271 | Properties: 272 | ResourceId: !Ref APIGatewayResource 273 | RestApiId: !Ref APIGateway 274 | HttpMethod: GET 275 | AuthorizationType: NONE 276 | Integration: 277 | Type: AWS_PROXY 278 | IntegrationHttpMethod: POST 279 | Uri: !Join ["", ["arn:aws:apigateway:", !Ref "AWS::Region", ":lambda:path/2015-03-31/functions/", !GetAtt LambdaRender.Arn, "/invocations"]] 280 | IntegrationResponses: 281 | - StatusCode: 200 282 | ResponseTemplates: 283 | application/json: "" 284 | ResponseParameters: 285 | method.response.header.Access-Control-Allow-Origin: "'*'" 286 | MethodResponses: 287 | - StatusCode: 200 288 | ResponseModels: 289 | application/json: "Empty" 290 | ResponseParameters: 291 | method.response.header.Access-Control-Allow-Origin: False 292 | 293 | APIGWOptionsMethod: 294 | Type: AWS::ApiGateway::Method 295 | Properties: 296 | ResourceId: !Ref APIGatewayResource 297 | RestApiId: !Ref APIGateway 298 | HttpMethod: OPTIONS 299 | AuthorizationType: NONE 300 | Integration: 301 | Type: MOCK 302 | IntegrationResponses: 303 | - StatusCode: 200 304 | ResponseParameters: 305 | method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'" 306 | method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS'" 307 | method.response.header.Access-Control-Allow-Origin: "'*'" 308 | PassthroughBehavior: WHEN_NO_MATCH 309 | RequestTemplates: 310 | application/json: "{'statusCode': 200}" 311 | MethodResponses: 312 | - StatusCode: 200 313 | ResponseModels: 314 | application/json: "Empty" 315 | ResponseParameters: 316 | method.response.header.Access-Control-Allow-Origin: False 317 | method.response.header.Access-Control-Allow-Headers: False 318 | method.response.header.Access-Control-Allow-Methods: False 319 | -------------------------------------------------------------------------------- /wallboard-editor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Wallboard Designer 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 35 | 36 | 37 |
38 |
39 |
All Settings
40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 160 |
161 |
162 |
Wallboard Cells
163 |
164 |
165 |
166 |
167 |
168 |

169 |       
170 | 171 | 172 | 874 | 875 | -------------------------------------------------------------------------------- /wallboard-example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Amazon Connect Wallboard 4 | 44 | 53 | 54 | 55 |

This is an example dashboard/wallboard for Amazon Connect

56 |

57 | In the Javascript above, set the "API_URI" variable to the 58 | following: 59 |

60 |

61 | https://API_Gateway_Invoke_URL/wallboard/?Wallboard=Wallboard_Identifier 62 |

63 |

64 | Where "API_Gateway_Invoke_URL" is found in the 65 | API Gateway console and the 66 | "Wallboard_Identifer" is what you've called your dashboard/wallboard 67 | in the DynamoDB table. This 68 | will have been configured in the YAML template when the dashboard/wallboard 69 | was created. 70 |

71 |

72 | The API returns a formatted HTML table that it places in the 73 | "wallboard" div below. Colours for cells are set in the 74 | dashboard/wallboard definition and are automatically returned in the table - 75 | however, you can choose to use additional styles in the calling HTML page to 76 | format the table. For example, you may choose to centre the data in each table 77 | cell. This is deliberately done so that customers may display their 78 | dashboard/wallboard however they like or can embed it in any HTML page as 79 | required. Individual cells have unique classes and labels; within each cell 80 | there is a separate div for static text and data. You'll find brief examples of 81 | this in the HTML <head> section above. 82 |

83 |

84 | You may also adjust the refresh interval by changing the 85 | "RefreshInterval" variable in the code above. 86 |

87 |
88 | 91 | 92 | -------------------------------------------------------------------------------- /wallboard-import.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # 4 | # Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 7 | # software and associated documentation files (the "Software"), to deal in the Software 8 | # without restriction, including without limitation the rights to use, copy, modify, 9 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so. 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 13 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 14 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 15 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 16 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 17 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | # 19 | 20 | import yaml 21 | import sys 22 | import signal 23 | import os 24 | import time 25 | import boto3 26 | from botocore.exceptions import NoCredentialsError 27 | 28 | # 29 | # Global variables 30 | # 31 | Dynamo = boto3.client('dynamodb') 32 | DDBTableName = os.environ.get('WallboardTable', 'ConnectWallboard') 33 | 34 | Settings = {} 35 | Calculations = [] 36 | Thresholds = [] 37 | AgentStates = {} 38 | Cells = [] 39 | DataSources = [] 40 | MaxColumns = 0 41 | MaxRows = 0 42 | 43 | # 44 | # Function definitions 45 | # 46 | 47 | def Interrupt(signal, frame): 48 | print('\n') 49 | sys.exit(0) 50 | 51 | def UpdateSettings(Config, Settings): 52 | if 'Defaults' in Config: 53 | Defaults = Config['Defaults'] 54 | if 'TextColour' in Defaults: Settings['TextColour'] = {'S':Defaults['TextColour']} 55 | if 'TextColor' in Defaults: Settings['TextColour'] = {'S':Defaults['TextColor']} 56 | if 'BackgroundColour' in Defaults: Settings['BackgroundColour'] = {'S':Defaults['BackgroundColour']} 57 | if 'BackgroundColor' in Defaults: Settings['BackgroundColour'] = {'S':Defaults['BackgroundColor']} 58 | if 'TextSize' in Defaults: Settings['TextSize'] = {'S':str(Defaults['TextSize'])} 59 | if 'Font' in Defaults: Settings['Font'] = {'S':Defaults['Font']} 60 | if 'WarningBackgroundColour' in Defaults: Settings['WarningBackgroundColour'] = {'S':Defaults['WarningBackgroundColour']} 61 | if 'WarningBackgroundColor' in Defaults: Settings['WarningBackgroundColour'] = {'S':Defaults['WarningBackgroundColor']} 62 | if 'AlertBackgroundColour' in Defaults: Settings['AlertBackgroundColour'] = {'S':Defaults['AlertBackgroundColour']} 63 | if 'AlertBackgroundColor' in Defaults: Settings['AlertBackgroundColour'] = {'S':Defaults['AlertBackgroundColor']} 64 | 65 | def GetCalculations(CalculationsConfig): 66 | Calculations = [] 67 | 68 | for Calc in CalculationsConfig: 69 | if 'Formula' not in Calc: 70 | print(f'Missing formula calculation in {Calc["ReferenceName"]}') 71 | sys.exit(1) 72 | 73 | Calculations.append({'Name':{'S':str(Calc['Calculation'])}, 'Formula':{'S':Calc['Formula']}}) 74 | 75 | return Calculations 76 | 77 | def GetThresholds(ThresholdConfig): 78 | Thresholds = [] 79 | 80 | for Threshold in ThresholdConfig: 81 | if 'Reference' not in Threshold: 82 | print(f'Missing reference in threshold {Threshold["ReferenceName"]}') 83 | sys.exit(1) 84 | if 'WarnBelow' not in Threshold and 'AlertBelow' not in Threshold and \ 85 | 'WarnAbove' not in Threshold and 'AlertAbove' not in Threshold: 86 | print(f'No actual threshold set in threshold {Threshold["ReferenceName"]}') 87 | sys.exit(1) 88 | 89 | Item = {} 90 | Item['Name'] = {'S':str(Threshold['Threshold'])} # Stringify just in case this is a numeric 91 | Item['Reference'] = {'S':Threshold['Reference']} 92 | if 'WarnBelow' in Threshold: Item['WarnBelow'] = {'S':str(Threshold['WarnBelow'])} 93 | if 'AlertBelow' in Threshold: Item['AlertBelow'] = {'S':str(Threshold['AlertBelow'])} 94 | if 'WarnAbove' in Threshold: Item['WarnAbove'] = {'S':str(Threshold['WarnAbove'])} 95 | if 'AlertAbove' in Threshold: Item['AlertAbove'] = {'S':str(Threshold['AlertAbove'])} 96 | 97 | Thresholds.append(Item) 98 | 99 | return Thresholds 100 | 101 | def GetAgentStates(AgentConfig): 102 | StateColours = [] 103 | 104 | for Item in AgentConfig: 105 | State = {} 106 | State['StateName'] = {'S':Item['State'].lower()} 107 | if 'Colour' in Item: State['BackgroundColour'] = {'S':Item['Colour'].lower()} 108 | if 'Color' in Item: State['BackgroundColour'] = {'S':Item['Color'].lower()} 109 | StateColours.append(State) 110 | 111 | return StateColours 112 | 113 | def GetDataSources(SourceConfig): 114 | Sources = [] 115 | Connect = boto3.client('connect') 116 | Boto3Warning = False 117 | 118 | 119 | for Item in SourceConfig: 120 | SourceInfo = {} 121 | SourceInfo['Name'] = {'S':Item['Source']} 122 | SourceInfo['Reference'] = {'S':Item['Reference']} 123 | Sources.append(SourceInfo) 124 | 125 | # 126 | # Just in case, check the references given and see if we can confirm 127 | # if the queue and Connect instance exist. This helps if there is a 128 | # typo in the definition file. 129 | # 130 | try: 131 | (InstanceId,QueueId,Metric) = Item['Reference'].split(':') 132 | except Exception as e: 133 | print(f'Check formatting of {Item["Source"]}: {e}') 134 | continue 135 | 136 | try: 137 | QueueResponse = Connect.list_queues(InstanceId=InstanceId) 138 | except AttributeError: 139 | if not Boto3Warning: 140 | print('Could not get boto3 response - are you using the latest version?') 141 | print(' -> Unable to verify if the reference values are correct') 142 | Boto3Warning = True 143 | continue 144 | except NoCredentialsError: 145 | print('FATAL: No AWS credentials could be found') 146 | sys.exit(1) 147 | except Exception as e: 148 | print(f'{Item["Source"]}: The InstanceId may be incorrect: {InstanceId}') 149 | print(e) 150 | continue 151 | 152 | QueueList = [] 153 | for Queue in QueueResponse['QueueSummaryList']: 154 | QueueList.append(Queue['Id']) 155 | 156 | if QueueId not in QueueList: 157 | print(f'{Item["Source"]}: The QueueId may be incorrect: {QueueId}') 158 | 159 | return Sources 160 | 161 | def GetCells(RowConfig): 162 | Cells = [] 163 | Columns = 0 164 | Rows = 0 165 | 166 | for Row in RowConfig: 167 | if 'Row' not in Row: 168 | print('Missing row number') 169 | sys.exit(1) 170 | if 'Cells' not in Row: 171 | print(f'Missing cell definitions on row {Row["Row"]}') 172 | sys.exit(1) 173 | 174 | # 175 | # We capture the maximum number of columns because it makes 176 | # our lives easier to know this during the render function 177 | # 178 | if len(Row['Cells']) > Columns: Columns = len(Row['Cells']) 179 | 180 | for Cell in Row['Cells']: 181 | if 'Cell' not in Cell: 182 | print(f'Missing cell number on row {Row["Row"]}') 183 | sys.exit(1) 184 | 185 | if int(Row['Row']) > Rows: Rows = int(Row['Row']) 186 | 187 | Item = {} 188 | Item['Address'] = {'S':f'R{Row["Row"]}C{Cell["Cell"]}'} 189 | 190 | if 'Text' in Cell: Item['Text'] = {'S':Cell['Text']} 191 | if 'Reference' in Cell: Item['Reference'] = {'S':Cell['Reference']} 192 | if 'TextColour' in Cell: Item['TextColour'] = {'S':Cell['TextColour']} 193 | if 'TextColor' in Cell: Item['TextColour'] = {'S':Cell['TextColor']} 194 | if 'BackgroundColour' in Cell: Item['BackgroundColour'] = {'S':Cell['BackgroundColour']} 195 | if 'BackgroundColor' in Cell: Item['BackgroundColour'] = {'S':Cell['BackgroundColor']} 196 | if 'TextSize' in Cell: Item['TextSize'] = {'S':str(Cell['TextSize'])} 197 | if 'ThresholdReference' in Cell: Item['ThresholdReference'] = {'S':Cell['ThresholdReference']} 198 | if 'Rows' in Cell: Item['Rows'] = {'S':str(Cell['Rows'])} 199 | if 'Cells' in Cell: Item['Cells'] = {'S':str(Cell['Cells'])} 200 | if 'Format' in Cell: Item['Format'] = {'S':str(Cell['Format'])} 201 | 202 | Cells.append(Item) 203 | 204 | return Cells,Rows,Columns 205 | 206 | def SaveToDynamoDB(WallboardName,Records,RecordType): 207 | global Dynamo 208 | 209 | Count = 0 210 | 211 | for Item in Records: 212 | Item['Identifier'] = {'S':WallboardName} 213 | if RecordType != 'Settings': 214 | Item['RecordType'] = {'S':f'{RecordType}{Count}'} 215 | Count += 1 216 | else: 217 | Item['RecordType'] = {'S':RecordType} 218 | 219 | try: 220 | Dynamo.put_item(TableName=DDBTableName, Item=Item) 221 | except NoCredentialsError: 222 | print('FATAL: No AWS credentials could be found') 223 | sys.exit(1) 224 | except Exception as e: 225 | print(f'DynamoDB error: {e}') 226 | 227 | def CreateDDBTable(): 228 | global Dynamo 229 | 230 | try: 231 | Response = Dynamo.describe_table(TableName=DDBTableName) 232 | except NoCredentialsError: 233 | print('FATAL: No AWS credentials could be found') 234 | sys.exit(1) 235 | except: 236 | Table = Dynamo.create_table(TableName=DDBTableName, 237 | KeySchema=[{'AttributeName':'Identifier', 'KeyType':'HASH'}, 238 | {'AttributeName':'RecordType', 'KeyType':'RANGE'}], 239 | AttributeDefinitions=[{'AttributeName':'Identifier', 'AttributeType':'S'}, {'AttributeName':'RecordType', 'AttributeType':'S'}], 240 | BillingMode='PAY_PER_REQUEST') 241 | 242 | Table = Dynamo.describe_table(TableName=DDBTableName) 243 | while Table['Table']['TableStatus'] != 'ACTIVE': 244 | print(f'Waiting for table creation. State: {Table["Table"]["TableStatus"]}') 245 | time.sleep(10) 246 | Table = Dynamo.describe_table(TableName=DDBTableName) 247 | 248 | # 249 | # Mainline code 250 | # 251 | # Basic setup and argument check 252 | # 253 | 254 | signal.signal(signal.SIGINT, Interrupt) 255 | 256 | if len(sys.argv) != 2: 257 | print('Usage: wallboard-import.py wallboarddefinition.yaml') 258 | sys.exit(1) 259 | 260 | # 261 | # Read the YAML file 262 | # 263 | 264 | with open(sys.argv[1]) as Input: 265 | try: 266 | Config = yaml.safe_load(Input) 267 | except yaml.YAMLError as e: 268 | print(e) 269 | sys.exit(1) 270 | 271 | CreateDDBTable() 272 | 273 | Settings['WarningBackgroundColour'] = {'S':'Yellow'} 274 | Settings['AlertBackgroundColour'] = {'S':'Red'} 275 | 276 | # 277 | # Input validation 278 | # 279 | 280 | if 'Identifier' not in Config: 281 | print('Missing Identifier tag') 282 | sys.exit(1) 283 | 284 | if 'Rows' not in Config: 285 | print('Missing row definitions') 286 | sys.exit(1) 287 | 288 | # 289 | # Somewhat validated now - let's parse the input 290 | # 291 | 292 | UpdateSettings(Config, Settings) 293 | if 'Calculations' in Config: Calculations = GetCalculations(Config['Calculations']) 294 | if 'Thresholds' in Config: Thresholds = GetThresholds(Config['Thresholds']) 295 | if 'AgentStates' in Config: AgentStates = GetAgentStates(Config['AgentStates']) 296 | if 'Sources' in Config: DataSources = GetDataSources(Config['Sources']) 297 | (Cells, MaxRows, MaxColumns) = GetCells(Config['Rows']) 298 | 299 | if MaxRows == 0: 300 | print('No rows were found') 301 | sys.exit(1) 302 | if MaxColumns == 0: 303 | print('No cells were found') 304 | sys.exit(1) 305 | 306 | Settings['Columns'] = {'S':str(MaxColumns)} 307 | Settings['Rows'] = {'S':str(MaxRows)} 308 | 309 | SaveToDynamoDB(Config['Identifier'], [Settings], 'Settings') 310 | SaveToDynamoDB(Config['Identifier'], Thresholds, 'Threshold') 311 | SaveToDynamoDB(Config['Identifier'], Calculations, 'Calculation') 312 | SaveToDynamoDB(Config['Identifier'], Cells, 'Cell') 313 | SaveToDynamoDB(Config['Identifier'], AgentStates, 'AgentState') 314 | SaveToDynamoDB(Config['Identifier'], DataSources, 'DataSource') 315 | --------------------------------------------------------------------------------