diff --git a/README.md b/README.md index f8c7358..1026f46 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,102 @@ # sdp-request-watcher +A local Node.js dashboard for ManageEngine ServiceDesk Plus (SDP) Cloud. It shows each technician's open tickets by status, plays a sound when a ticket is closed, tracks the unassigned queue, and shows local weather. -## Env and Server Setup -env.example contains the setup for the env requires a Non-Browser Application key from https://api-console.zoho.com/add -Place the .env file inside the src directory +![Dashboard](images/4.png) -Technicians and Statuses are checked in the .env file +## Setup -I resolved the issue with it only finding closed tickets so it should now find resolved and closed I havent tested with others but will +### 1. Zoho API client -The technician field can have spaces after the comma however the statuses can not. There can be no spaces before the status or it wont work properly. +Create a **Non-Browser Application** at . That gives you the client ID and secret. The app only asks for the `SDPOnDemand.requests.READ` scope, so it can read requests but never change them. +### 2. `.env` file +Copy `src/env.example` to a file named `.env` and fill it in. dotenv reads `.env` from the folder you start the server in, so keep `.env` in the project root and start the server from there (see step 3). -## Site Setup -Open the site after the server is running, then connect the Zoho account from the board. +| Setting | Required | What it does | +| --- | --- | --- | +| `CLIENT_ID`, `CLIENT_SECRET` | Yes | The Zoho Non-Browser Application credentials. | +| `TECHNICIAN_NAMES` | No | Comma-separated technician names, spelled as they appear in SDP. One table row per name. Leave it empty to include every technician. | +| `STATUS_NAMES` | No | Statuses that count as "closed" for the ding and the Closed today count. Everything else counts as open. Defaults to `Closed,Resolved,Canceled`. | +| `SITE_NAMES` | No | Only include requests from these sites (the region). Leave it empty for all sites. | +| `GROUP_NAMES` | No | Only include requests in these support groups, e.g. `Customer Support`. Leave it empty for all groups. | +| `UNASSIGNED_EXCLUDED_STATUSES` | No | Statuses the Unassigned requests counter leaves out. Defaults to `STATUS_NAMES`. | +| `WORKLOAD_MINUTES` | No | How often the status table and unassigned count refresh, in minutes. Defaults to 5. | +| `OWM_API_KEY`, `OWM_ZIP_CODE` | No | OpenWeatherMap key and zip code for the weather card. Leave either one empty to hide the card. A free key from is enough. New keys can take a couple of hours to start working. | -1. Start the server from the `src` folder with node ./server.js -2. Open the local dashboard in your browser. Should be localhost:3000 -3. Click **Connect** to begin the Zoho authorization flow. -4. Approve the device code on the Zoho page, then return to the dashboard. +Spaces around commas are fine in every list. -What you should see: +The central region settings currently in use: -![Site setup step 1](images/1.png) +```text +SITE_NAMES=Base Site,ASSIGN,CMCC,CMCC-Damariscotta,EMCC,KVCC,KVCC-Alfond,MCCS +GROUP_NAMES=Customer Support +UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved +``` -![Site setup step 2](images/2.png) +These match the "Central Region Unassigned" custom view in ManageEngine. The Unassigned requests count was checked against that view and matched. -![Site setup step 3](images/3.png) +### 3. Run it -![Site setup step 4](images/4.png) +```text +npm install +node src/server.js +``` -Once connected, the board will begin checking for tickets automatically and refreshing on its own based on the time set in the server.js file. +Then open . Set `PORT` in `.env` to use a different port. + +## Connecting to Zoho + +The token is kept in memory only, so you connect each time the server starts. + +1. Click **Connect**. + + ![Connect screen](images/1.png) + +2. Once Zoho sends back a device code, the code and an **Open authorization page** button appear. The Zoho page opens in a new tab. + + ![Approve screen](images/2.png) + +3. Check that the code matches, tick the box and click **Accept**, then close that tab. + + ![Zoho consent page](images/3.png) + +4. Return to the dashboard tab. Zoho can't redirect back to a local app, but the dashboard checks the server every few seconds and goes live by itself once you approve. + +## The dashboard + +- **Stat tiles**: unassigned requests, total open requests, closed today, closed in the last hour, and the technician with the most open tickets. +- **Tickets by technician**: each technician's open tickets by status. **Total open** and **Closed today** come first, so they stay visible on narrow screens while the status columns scroll sideways. Darker cells mean more tickets. A row flashes when that technician closes a ticket. +- **Weather**: current conditions and a 5-day forecast for `OWM_ZIP_CODE`, refreshed every 10 minutes. +- **Recently closed**: the live closed-ticket feed, checked every minute. A ding plays for each new close. The sound is on by default, and the **Sound on close** toggle mutes it in that browser. +- **Refresh now** reruns every check immediately. + +If a query fails, a red error box shows what SDP returned, and the other sections keep working. If the table ever shows **partial**, a query matched more tickets than it can page through (2,000). The server console then logs the details, including SDP's total count. A very large total usually means SDP ignored the search criteria. + +### Unassigned verification list + +`src/public/index.html` still has a hidden table listing every unassigned request by ID and subject. It was used to confirm the count matches the ManageEngine view. To show it again, set `SHOW_UNASSIGNED_LIST = true`. + +## How it works + +| File | Role | +| --- | --- | +| `src/server.js` | Reads `.env`, then starts Express and the services. | +| `src/auth.js` | Zoho device-code sign-in. | +| `src/boardService.js` | Holds the token and state, and runs the refresh timers. | +| `src/tickets.js` | SDP API queries for the closed feed, the status table, closed today and unassigned requests. | +| `src/weather.js` | OpenWeatherMap current weather and forecast, cached for 10 minutes. | +| `src/routes.js` | `/api/state`, `/api/connect`, `/api/check` and `/api/weather`. | +| `src/public/index.html` | The dashboard page. | + +Every SDP query is limited to the configured sites, groups and technicians. Results are also re-checked locally, so a ticket from outside the region is never counted even if the API returns one. ## Requirements -axios -dotenv +Node.js 18 or newer. Dependencies are `axios`, `dotenv` and `express`, all installed by `npm install`. -## Reference Materials/ Provided by Skylar +## Reference materials (provided by Skylar) - [Non-Browser Apps - Overview](https://www.zoho.com/accounts/protocol/oauth/limited-input-devices.html) - [Non-Browser Apps - Initiation Request](https://www.zoho.com/accounts/protocol/oauth/devices/initiation-request.html) @@ -46,3 +104,4 @@ dotenv - [Get List Request](https://www.manageengine.com/products/service-desk/sdpod-v3-api/requests/request.html#get-list-request) - [Input Data](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/input-data.html) - [Search Criteria](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/search-criteria.html) +- [OpenWeatherMap Current Weather](https://openweathermap.org/current) and [5 Day Forecast](https://openweathermap.org/forecast5) diff --git a/images/1.png b/images/1.png index 43de6be..cdfe915 100644 Binary files a/images/1.png and b/images/1.png differ diff --git a/images/2.png b/images/2.png index 9d6d016..9ed5133 100644 Binary files a/images/2.png and b/images/2.png differ diff --git a/images/4.png b/images/4.png index 0e214a1..b3dd0c0 100644 Binary files a/images/4.png and b/images/4.png differ diff --git a/package-lock.json b/package-lock.json index e5e7068..68dc34f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,19 @@ { - "name": "Dashboard", + "name": "sdp-request-watcher", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { + "name": "sdp-request-watcher", + "version": "1.0.0", + "license": "ISC", "dependencies": { - "axios": "^1.13.2", - "dotenv": "^17.4.2", - "express": "^5.2.1" - } + "axios": "1.x", + "dotenv": "18.x", + "express": "5.x" + }, + "devDependencies": {} }, "node_modules/accepts": { "version": "2.0.0", @@ -228,10 +233,13 @@ } }, "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "version": "18.0.8", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz", + "integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==", "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 4df187d..6bf11e5 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,13 @@ "type": "commonjs", "main": "src/server.js", "scripts": { + "app": "node src/server.js", "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "axios": "^1.13.2", - "dotenv": "^17.4.2", - "express": "^5.2.1" + "axios": "1.x", + "dotenv": "18.x", + "express": "5.x" }, "devDependencies": {} } diff --git a/src/boardService.js b/src/boardService.js index bdc6aa6..ba36d49 100644 --- a/src/boardService.js +++ b/src/boardService.js @@ -1,7 +1,7 @@ const { startAuthentication } = require('./auth'); -const { fetchClosedTickets } = require('./tickets'); +const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets'); -function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes }) { +function createBoardService({ clientId, clientSecret, technicians, statusSet, closedStatuses, unassignedExcludedStatuses, scope, windowMinutes, checkMinutes, workloadMinutes }) { const state = { auth: 'disconnected', verificationUrl: null, @@ -10,9 +10,27 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi lastError: null }; + const workload = { + updatedAt: null, + error: null, + truncated: false, + statuses: [], + technicians: [] + }; + + const unassigned = { + updatedAt: null, + error: null, + truncated: false, + count: 0, + discarded: 0, + tickets: [] + }; + const tickets = new Map(); let accessToken = null; let pollingTimer = null; + let workloadTimer = null; async function refreshTickets() { if (!accessToken) return; @@ -23,10 +41,30 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi tickets, technicians, statusSet, + scope, windowMinutes }); } + async function refreshWorkload() { + if (!accessToken) return; + + await fetchWorkload({ + accessToken, + workload, + technicians, + closedStatuses, + scope + }); + + await fetchUnassigned({ + accessToken, + unassigned, + excludedStatuses: unassignedExcludedStatuses, + scope + }); + } + function startPolling() { if (pollingTimer) return; @@ -36,6 +74,13 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi state.lastCheck = Date.now(); }); }, checkMinutes * 60 * 1000); + + workloadTimer = setInterval(() => { + refreshWorkload().catch(err => { + workload.error = err.message; + workload.updatedAt = Date.now(); + }); + }, workloadMinutes * 60 * 1000); } async function connect() { @@ -52,6 +97,7 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi await refreshTickets(); startPolling(); + await refreshWorkload(); } function failConnection(err) { @@ -66,14 +112,18 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi ...state, windowMinutes, checkMinutes, + workloadMinutes, watching: technicians, + scope, serverTime: Date.now(), - tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt) + tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt), + workload, + unassigned }; } async function checkNow() { - await refreshTickets(); + await Promise.all([refreshTickets(), refreshWorkload()]); } return { @@ -86,4 +136,4 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi module.exports = { createBoardService -}; \ No newline at end of file +}; diff --git a/src/env.example b/src/env.example index b1fd72e..3899f6b 100644 --- a/src/env.example +++ b/src/env.example @@ -1,4 +1,15 @@ CLIENT_ID= CLIENT_SECRET= TECHNICIAN_NAMES=First Last, First Last -STATUS_NAMES=Closed,Resolved \ No newline at end of file +STATUS_NAMES=Closed,Resolved +# only requests from these sites are shown (central region: KVCC, EMCC, CMCC, MCCS and their satellite sites) +SITE_NAMES=Base Site,ASSIGN,CMCC,CMCC-Damariscotta,EMCC,KVCC,KVCC-Alfond,MCCS +# optional: also limit to these support groups, e.g. Customer Support (leave empty for all groups) +GROUP_NAMES= +# statuses the Unassigned Requests counter leaves out (defaults to STATUS_NAMES) +UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved +# optional: weather widget, free key from https://openweathermap.org/api (current weather + 5 day forecast) +OWM_API_KEY= +OWM_ZIP_CODE=04937 +# optional: how often the technician/status table is recounted, in minutes (default 5) +WORKLOAD_MINUTES=5 \ No newline at end of file diff --git a/src/public/index.html b/src/public/index.html index 56fce42..781f781 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -3,19 +3,19 @@ -Closed ticket board +Help desk dashboard - +