Slopify This Thang
This commit is contained in:
parent
3e592f9a0d
commit
8c7b5fbd26
101
README.md
101
README.md
@ -1,44 +1,102 @@
|
|||||||
# sdp-request-watcher
|
# 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
|
|
||||||
|
|
||||||
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 <https://api-console.zoho.com/add>. 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
|
| Setting | Required | What it does |
|
||||||
Open the site after the server is running, then connect the Zoho account from the board.
|
| --- | --- | --- |
|
||||||
|
| `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 <https://openweathermap.org/api> 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
|
Spaces around commas are fine in every list.
|
||||||
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.
|
|
||||||
|
|
||||||
What you should see:
|
The central region settings currently in use:
|
||||||
|
|
||||||

|
```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
|
||||||
|
```
|
||||||
|
|
||||||

|
These match the "Central Region Unassigned" custom view in ManageEngine. The Unassigned requests count was checked against that view and matched.
|
||||||
|
|
||||||

|
### 3. Run it
|
||||||
|
|
||||||

|
```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 <http://localhost:3000>. 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**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
3. Check that the code matches, tick the box and click **Accept**, then close that tab.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
## 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 - 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)
|
- [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)
|
- [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)
|
- [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)
|
- [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)
|
||||||
|
|||||||
BIN
images/1.png
BIN
images/1.png
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 28 KiB |
BIN
images/2.png
BIN
images/2.png
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 32 KiB |
BIN
images/4.png
BIN
images/4.png
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 73 KiB |
24
package-lock.json
generated
24
package-lock.json
generated
@ -1,14 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "Dashboard",
|
"name": "sdp-request-watcher",
|
||||||
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"name": "sdp-request-watcher",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "1.x",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "18.x",
|
||||||
"express": "^5.2.1"
|
"express": "5.x"
|
||||||
}
|
},
|
||||||
|
"devDependencies": {}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@ -228,10 +233,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.4.2",
|
"version": "18.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz",
|
||||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
"integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"bin": {
|
||||||
|
"dotenv": "dist/index.cjs"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -11,12 +11,13 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"app": "node src/server.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "1.x",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "18.x",
|
||||||
"express": "^5.2.1"
|
"express": "5.x"
|
||||||
},
|
},
|
||||||
"devDependencies": {}
|
"devDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
const { startAuthentication } = require('./auth');
|
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 = {
|
const state = {
|
||||||
auth: 'disconnected',
|
auth: 'disconnected',
|
||||||
verificationUrl: null,
|
verificationUrl: null,
|
||||||
@ -10,9 +10,27 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
lastError: null
|
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();
|
const tickets = new Map();
|
||||||
let accessToken = null;
|
let accessToken = null;
|
||||||
let pollingTimer = null;
|
let pollingTimer = null;
|
||||||
|
let workloadTimer = null;
|
||||||
|
|
||||||
async function refreshTickets() {
|
async function refreshTickets() {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
@ -23,10 +41,30 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
tickets,
|
tickets,
|
||||||
technicians,
|
technicians,
|
||||||
statusSet,
|
statusSet,
|
||||||
|
scope,
|
||||||
windowMinutes
|
windowMinutes
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshWorkload() {
|
||||||
|
if (!accessToken) return;
|
||||||
|
|
||||||
|
await fetchWorkload({
|
||||||
|
accessToken,
|
||||||
|
workload,
|
||||||
|
technicians,
|
||||||
|
closedStatuses,
|
||||||
|
scope
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchUnassigned({
|
||||||
|
accessToken,
|
||||||
|
unassigned,
|
||||||
|
excludedStatuses: unassignedExcludedStatuses,
|
||||||
|
scope
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
if (pollingTimer) return;
|
if (pollingTimer) return;
|
||||||
|
|
||||||
@ -36,6 +74,13 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
state.lastCheck = Date.now();
|
state.lastCheck = Date.now();
|
||||||
});
|
});
|
||||||
}, checkMinutes * 60 * 1000);
|
}, checkMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
workloadTimer = setInterval(() => {
|
||||||
|
refreshWorkload().catch(err => {
|
||||||
|
workload.error = err.message;
|
||||||
|
workload.updatedAt = Date.now();
|
||||||
|
});
|
||||||
|
}, workloadMinutes * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
@ -52,6 +97,7 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
|
|
||||||
await refreshTickets();
|
await refreshTickets();
|
||||||
startPolling();
|
startPolling();
|
||||||
|
await refreshWorkload();
|
||||||
}
|
}
|
||||||
|
|
||||||
function failConnection(err) {
|
function failConnection(err) {
|
||||||
@ -66,14 +112,18 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
...state,
|
...state,
|
||||||
windowMinutes,
|
windowMinutes,
|
||||||
checkMinutes,
|
checkMinutes,
|
||||||
|
workloadMinutes,
|
||||||
watching: technicians,
|
watching: technicians,
|
||||||
|
scope,
|
||||||
serverTime: Date.now(),
|
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() {
|
async function checkNow() {
|
||||||
await refreshTickets();
|
await Promise.all([refreshTickets(), refreshWorkload()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -86,4 +136,4 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createBoardService
|
createBoardService
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,15 @@
|
|||||||
CLIENT_ID=
|
CLIENT_ID=
|
||||||
CLIENT_SECRET=
|
CLIENT_SECRET=
|
||||||
TECHNICIAN_NAMES=First Last, First Last
|
TECHNICIAN_NAMES=First Last, First Last
|
||||||
STATUS_NAMES=Closed,Resolved
|
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
|
||||||
@ -3,19 +3,19 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Closed ticket board</title>
|
<title>Help desk dashboard</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|
||||||
--ground: hsl(210, 18%, 8%);
|
--ground: hsl(210, 18%, 8%);
|
||||||
--card: hsl(210, 16%, 13%);
|
--card: hsl(210, 16%, 13%);
|
||||||
|
--card-raised: hsl(210, 15%, 16%);
|
||||||
--ink: hsl(30, 30%, 96%);
|
--ink: hsl(30, 30%, 96%);
|
||||||
--muted: hsl(215, 12%, 68%);
|
--muted: hsl(215, 12%, 68%);
|
||||||
|
--faint: hsl(215, 10%, 42%);
|
||||||
--rule: hsl(210, 12%, 24%);
|
--rule: hsl(210, 12%, 24%);
|
||||||
--signal: hsl(28, 95%, 58%);
|
--signal: hsl(28, 95%, 58%);
|
||||||
--signal-soft: hsl(28, 70%, 22%);
|
--signal-soft: hsl(28, 70%, 22%);
|
||||||
@ -27,6 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -42,10 +43,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
max-width: 860px;
|
width: 100%;
|
||||||
|
max-width: 1320px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 20px 80px;
|
padding: 28px 24px 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- header ---- */
|
/* ---- header ---- */
|
||||||
@ -53,7 +55,7 @@ body {
|
|||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 12px 20px;
|
gap: 12px 20px;
|
||||||
padding-bottom: 14px;
|
padding-bottom: 14px;
|
||||||
border-bottom: 2px solid var(--ink);
|
border-bottom: 2px solid var(--ink);
|
||||||
@ -71,7 +73,6 @@ h1 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
margin-left: auto;
|
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@ -96,6 +97,13 @@ h1 {
|
|||||||
50% { opacity: 0.25; }
|
50% { opacity: 0.25; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -124,7 +132,7 @@ h1 {
|
|||||||
.panel p {
|
.panel p {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
max-width: 52ch;
|
max-width: 80ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code {
|
.code {
|
||||||
@ -165,6 +173,40 @@ button.quiet {
|
|||||||
text-underline-offset: 3px;
|
text-underline-offset: 3px;
|
||||||
}
|
}
|
||||||
button.quiet:hover { background: none; color: var(--ink); }
|
button.quiet:hover { background: none; color: var(--ink); }
|
||||||
|
button.quiet:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.toggle:hover { background: none; color: var(--ink); }
|
||||||
|
.toggle .track {
|
||||||
|
position: relative;
|
||||||
|
width: 26px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--rule);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.toggle .track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
transition: transform 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.toggle[aria-pressed="true"] .track { background: var(--signal-soft); }
|
||||||
|
.toggle[aria-pressed="true"] .track::after { transform: translateX(12px); background: var(--signal); }
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
@ -175,55 +217,188 @@ button.quiet:hover { background: none; color: var(--ink); }
|
|||||||
}
|
}
|
||||||
.error strong { display: block; font-family: var(--mono); font-size: 12px; color: var(--alert); }
|
.error strong { display: block; font-family: var(--mono); font-size: 12px; color: var(--alert); }
|
||||||
|
|
||||||
/* ---- tallies ---- */
|
/* ---- stat tiles ---- */
|
||||||
|
|
||||||
.tallies {
|
.stats {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tally {
|
.stat {
|
||||||
display: flex;
|
padding: 16px 18px;
|
||||||
align-items: baseline;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 5px 12px;
|
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
.tally b {
|
.stat .label {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.stat .value {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 30px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--signal);
|
line-height: 1.1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stat.accent .value { color: var(--signal); }
|
||||||
|
.stat .sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- board layout ---- */
|
||||||
|
|
||||||
|
.board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 380px;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 12px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.section-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.section-head .note {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- workload table ---- */
|
||||||
|
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 9px 14px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
th .swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-right: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--status-color, var(--muted));
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:first-child, td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background: var(--card);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
td:first-child { font-weight: 500; }
|
||||||
|
|
||||||
|
tbody tr + tr td { border-top: 1px solid color-mix(in oklab, var(--rule), transparent 50%); }
|
||||||
|
tbody tr:hover td { background: var(--card-raised); }
|
||||||
|
|
||||||
|
td.num {
|
||||||
|
--heat: 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: color-mix(in oklab, var(--signal-soft) calc(var(--heat) * 100%), transparent);
|
||||||
|
}
|
||||||
|
tbody tr:hover td.num { background: color-mix(in oklab, var(--signal-soft) calc(var(--heat) * 100%), var(--card-raised)); }
|
||||||
|
td.zero { color: var(--faint); }
|
||||||
|
|
||||||
|
/* totals and closed-today sit apart from the per-status columns */
|
||||||
|
td.total { font-weight: 600; background: none; }
|
||||||
|
th.closed, td.closed { border-right: 1px solid var(--rule); }
|
||||||
|
td.closed { color: var(--signal); background: none; }
|
||||||
|
td.closed.zero { color: var(--faint); }
|
||||||
|
tfoot td.closed { color: var(--ink); }
|
||||||
|
|
||||||
|
tfoot td {
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
tfoot td:first-child { font-family: var(--font-ui); color: var(--muted); font-weight: 500; }
|
||||||
|
|
||||||
|
tr.flash td { animation: flash 2.4s ease-out; }
|
||||||
|
@keyframes flash {
|
||||||
|
from { background: var(--signal-soft); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-empty {
|
||||||
|
padding: 36px 18px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- feed ---- */
|
/* ---- feed ---- */
|
||||||
|
|
||||||
.feed { margin-top: 20px; }
|
.feed {
|
||||||
|
max-height: 640px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.ticket {
|
.ticket {
|
||||||
--age: 0;
|
--age: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 74px 1fr;
|
grid-template-columns: 66px 1fr;
|
||||||
background: var(--card);
|
border-bottom: 1px solid var(--rule);
|
||||||
border: 1px solid var(--rule);
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
.feed .ticket:last-child { border-bottom: 1px solid var(--rule); }
|
.feed .ticket:last-child { border-bottom: none; }
|
||||||
|
|
||||||
/* The rail is the point of the whole page: how long ago this closed, drained
|
/* how long ago this closed, drained of color as it ages out of the hour */
|
||||||
of color as it ages out of the window. */
|
|
||||||
.rail {
|
.rail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 14px 0 14px 14px;
|
padding: 12px 0 12px 12px;
|
||||||
border-left: 3px solid color-mix(in oklab, var(--signal), var(--rule) calc(var(--age) * 100%));
|
border-left: 3px solid color-mix(in oklab, var(--signal), var(--rule) calc(var(--age) * 100%));
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: color-mix(in oklab, var(--signal), var(--muted) calc(var(--age) * 100%));
|
color: color-mix(in oklab, var(--signal), var(--muted) calc(var(--age) * 100%));
|
||||||
}
|
}
|
||||||
@ -235,11 +410,11 @@ button.quiet:hover { background: none; color: var(--ink); }
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.body { padding: 14px 16px 14px 4px; min-width: 0; }
|
.body { padding: 12px 14px 12px 4px; min-width: 0; }
|
||||||
|
|
||||||
.subject {
|
.subject {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@ -251,8 +426,8 @@ button.quiet:hover { background: none; color: var(--ink); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.byline {
|
.byline {
|
||||||
margin: 3px 0 0;
|
margin: 2px 0 0;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.byline b { color: var(--ink); font-weight: 500; }
|
.byline b { color: var(--ink); font-weight: 500; }
|
||||||
@ -264,32 +439,102 @@ button.quiet:hover { background: none; color: var(--ink); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
margin-top: 20px;
|
|
||||||
padding: 40px 24px;
|
padding: 40px 24px;
|
||||||
background: var(--card);
|
|
||||||
border: 1px dashed var(--rule);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.empty p { margin: 0; }
|
.empty p { margin: 0; }
|
||||||
|
|
||||||
footer {
|
|
||||||
margin-top: 28px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
body > footer {
|
body > footer {
|
||||||
margin-top: auto;
|
|
||||||
max-width: 860px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 20px 28px;
|
max-width: 1320px;
|
||||||
|
margin: auto auto 0;
|
||||||
|
padding: 0 24px 28px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
body > footer p { margin: 0; }
|
||||||
|
|
||||||
|
/* ---- weather ---- */
|
||||||
|
|
||||||
|
.side { display: grid; gap: 20px; min-width: 0; }
|
||||||
|
|
||||||
|
.weather-now {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 12px;
|
||||||
|
padding: 12px 18px 14px 8px;
|
||||||
|
}
|
||||||
|
.weather-now img { width: 84px; height: 84px; margin: -6px 0; }
|
||||||
|
.weather-temp {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 38px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.weather-desc { margin-top: 4px; font-size: 14px; text-transform: capitalize; }
|
||||||
|
.weather-details {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px 14px;
|
||||||
|
padding-left: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.weather-details b { color: var(--ink); font-weight: 500; }
|
||||||
|
|
||||||
|
.forecast {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.forecast-day {
|
||||||
|
padding: 10px 4px 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.forecast-day + .forecast-day { border-left: 1px solid color-mix(in oklab, var(--rule), transparent 50%); }
|
||||||
|
.forecast-day .name { color: var(--muted); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||||
|
.forecast-day img { display: block; width: 44px; height: 44px; margin: 0 auto; }
|
||||||
|
.forecast-day .lo { color: var(--muted); }
|
||||||
|
.forecast-day .pop { margin-top: 2px; font-size: 11px; color: hsl(205, 70%, 66%); }
|
||||||
|
|
||||||
|
/* ---- temporary: unassigned verification list (not for prod) ---- */
|
||||||
|
|
||||||
|
.verify {
|
||||||
|
margin-top: 20px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px dashed var(--rule);
|
||||||
|
}
|
||||||
|
.verify summary {
|
||||||
|
padding: 12px 18px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.verify summary:hover { color: var(--ink); }
|
||||||
|
.verify[open] summary { border-bottom: 1px dashed var(--rule); }
|
||||||
|
.verify .warn { padding: 10px 18px 0; font-size: 13px; color: var(--alert); }
|
||||||
|
.verify td, .verify th { text-align: left; }
|
||||||
|
.verify td.subject-cell { white-space: normal; min-width: 260px; }
|
||||||
|
|
||||||
|
@media (max-width: 1040px) {
|
||||||
|
.board { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.feed { max-height: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 720px) {
|
||||||
.ticket { grid-template-columns: 62px 1fr; }
|
.wrap { padding: 20px 16px 40px; }
|
||||||
.rail { font-size: 13px; padding-left: 10px; }
|
body > footer { padding: 0 16px 24px; }
|
||||||
|
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.stat .value { font-size: 24px; }
|
||||||
|
.actions { margin-left: 0; width: 100%; }
|
||||||
|
th, td { padding: 8px 10px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@ -301,17 +546,21 @@ body > footer {
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>Closed ticket board</h1>
|
<h1>Help desk dashboard</h1>
|
||||||
<div class="status" id="status" data-auth="disconnected">
|
<div class="status" id="status" data-auth="disconnected">
|
||||||
<span class="dot"></span><span id="statusText">Not connected</span>
|
<span class="dot"></span><span id="statusText">Not connected</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="toggle" id="soundToggle" aria-pressed="true" onclick="toggleSound()">
|
||||||
|
<span class="track"></span>Sound on close
|
||||||
|
</button>
|
||||||
|
<button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="meta" id="meta"></div>
|
<div class="meta" id="meta"></div>
|
||||||
|
|
||||||
<div id="main"></div>
|
<div id="main"></div>
|
||||||
|
|
||||||
<footer id="footer"></footer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -327,11 +576,22 @@ function beep() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the ding is on by default; muting is remembered per browser
|
||||||
|
let soundOn = true;
|
||||||
|
try { soundOn = localStorage.getItem('sdp-sound') !== 'off'; } catch (err) {}
|
||||||
|
|
||||||
|
function toggleSound() {
|
||||||
|
soundOn = !soundOn;
|
||||||
|
try { localStorage.setItem('sdp-sound', soundOn ? 'on' : 'off'); } catch (err) {}
|
||||||
|
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
|
||||||
|
if (soundOn) beep();
|
||||||
|
}
|
||||||
|
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
|
||||||
|
|
||||||
let data = null;
|
let data = null;
|
||||||
let seen = new Set();
|
let seen = new Set();
|
||||||
let firstLoad = true;
|
let firstLoad = true;
|
||||||
|
let flashTechs = new Set();
|
||||||
|
|
||||||
const STATUS_TEXT = {
|
const STATUS_TEXT = {
|
||||||
disconnected: 'Not connected',
|
disconnected: 'Not connected',
|
||||||
@ -369,16 +629,22 @@ async function load() {
|
|||||||
function render() {
|
function render() {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
if (!firstLoad && data.tickets.some(t => !seen.has(t.id))) beep(); //Calls Beep
|
// the watcher: anything we haven't seen before is a fresh close
|
||||||
|
const fresh = data.tickets.filter(t => !seen.has(t.id));
|
||||||
|
flashTechs = new Set(firstLoad ? [] : fresh.map(t => t.technician));
|
||||||
|
if (!firstLoad && fresh.length && soundOn) beep();
|
||||||
|
|
||||||
$('status').dataset.auth = data.auth;
|
$('status').dataset.auth = data.auth;
|
||||||
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
|
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
|
||||||
|
$('checkBtn').hidden = data.auth !== 'connected';
|
||||||
|
|
||||||
|
const wl = data.workload || {};
|
||||||
$('meta').innerHTML = [
|
$('meta').innerHTML = [
|
||||||
'Checks every ' + data.checkMinutes + ' min',
|
'Closed tickets checked every ' + data.checkMinutes + ' min',
|
||||||
'Window ' + data.windowMinutes + ' min',
|
'Status counts every ' + data.workloadMinutes + ' min',
|
||||||
data.watching.length ? 'Watching ' + data.watching.length + ' technicians' : 'Watching all technicians',
|
data.watching.length ? 'Watching ' + data.watching.length + ' technicians' : 'Watching all technicians',
|
||||||
|
data.scope && data.scope.sites.length ? 'Sites: ' + data.scope.sites.join(', ') : '',
|
||||||
|
data.scope && data.scope.groups.length ? 'Groups: ' + data.scope.groups.join(', ') : '',
|
||||||
data.lastCheck ? 'Last check ' + clockTime(data.lastCheck) : ''
|
data.lastCheck ? 'Last check ' + clockTime(data.lastCheck) : ''
|
||||||
].filter(Boolean).map(t => '<span>' + esc(t) + '</span>').join('');
|
].filter(Boolean).map(t => '<span>' + esc(t) + '</span>').join('');
|
||||||
|
|
||||||
@ -393,45 +659,214 @@ function render() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.auth === 'pending') {
|
if (data.auth === 'pending' && !(data.userCode && data.verificationUrl)) {
|
||||||
|
// Zoho hasn't handed back the device code yet; nothing to open until it does
|
||||||
html += `<div class="panel">
|
html += `<div class="panel">
|
||||||
<h2>Approve the board</h2>
|
<h2>Approve the board</h2>
|
||||||
<p>Open the authorization page and confirm this code matches the one shown there.</p>
|
<p>Requesting a device code from Zoho…</p>
|
||||||
<div class="code">${esc(data.userCode || '...')}</div>
|
</div>`;
|
||||||
<a class="button" href="${esc(data.verificationUrl || '#')}">Open authorization page</a>
|
} else if (data.auth === 'pending') {
|
||||||
|
html += `<div class="panel">
|
||||||
|
<h2>Approve the board</h2>
|
||||||
|
<p>The authorization page opens in a new tab. Confirm this code matches the one shown there,
|
||||||
|
approve it, then close that tab. This page picks up the connection on its own.</p>
|
||||||
|
<div class="code">${esc(data.userCode)}</div>
|
||||||
|
<a class="button" href="${esc(data.verificationUrl)}" target="_blank" rel="noopener noreferrer">Open authorization page ↗</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.lastError) {
|
if (data.lastError) {
|
||||||
html += `<div class="error"><strong>Last check failed</strong>${esc(data.lastError)}</div>`;
|
html += `<div class="error"><strong>Last check failed</strong>${esc(data.lastError)}</div>`;
|
||||||
}
|
}
|
||||||
|
if (wl.error) {
|
||||||
if (data.auth === 'connected') {
|
html += `<div class="error"><strong>Status counts failed</strong>${esc(wl.error)}</div>`;
|
||||||
const tallies = {};
|
}
|
||||||
for (const t of data.tickets) tallies[t.technician] = (tallies[t.technician] || 0) + 1;
|
if (data.unassigned && data.unassigned.error) {
|
||||||
const names = Object.keys(tallies).sort((a, b) => tallies[b] - tallies[a]);
|
html += `<div class="error"><strong>Unassigned count failed</strong>${esc(data.unassigned.error)}</div>`;
|
||||||
|
|
||||||
if (names.length) {
|
|
||||||
html += '<div class="tallies">' + names.map(n =>
|
|
||||||
`<span class="tally">${esc(n)} <b>${tallies[n]}</b></span>`).join('') + '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.tickets.length) {
|
|
||||||
html += `<div class="empty"><p>Nothing closed yet.</p></div>`;
|
|
||||||
} else {
|
|
||||||
html += '<div class="feed">' + data.tickets.map(renderTicket).join('') + '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '<p style="margin-top:16px"><button class="quiet" onclick="checkNow(this)">Check now</button></p>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$('main').innerHTML = html;
|
if (data.auth === 'connected') {
|
||||||
|
html += renderStats(wl);
|
||||||
|
html += `<div class="board">
|
||||||
|
${renderWorkload(wl)}
|
||||||
|
<div class="side">
|
||||||
|
${renderWeather()}
|
||||||
|
${renderFeed()}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
html += renderUnassignedList(data.unassigned || {});
|
||||||
|
}
|
||||||
|
|
||||||
$('footer').textContent = data.tickets && data.tickets.length
|
// keep the verification list open across the 5s re-renders
|
||||||
? data.tickets.length + ' tickets on the board'
|
const verifyOpen = !!document.querySelector('.verify[open]');
|
||||||
|
$('main').innerHTML = html;
|
||||||
|
if (verifyOpen) document.querySelector('.verify').open = true;
|
||||||
|
|
||||||
|
for (const t of data.tickets) seen.add(t.id);
|
||||||
|
firstLoad = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats(wl) {
|
||||||
|
const techs = wl.technicians || [];
|
||||||
|
const loaded = !!wl.updatedAt && !wl.error;
|
||||||
|
const open = techs.reduce((n, t) => n + t.open, 0);
|
||||||
|
const closedToday = techs.reduce((n, t) => n + t.closedToday, 0);
|
||||||
|
const hourAgo = Date.now() - 60 * 60 * 1000;
|
||||||
|
const lastHour = data.tickets.filter(t => t.closedAt >= hourAgo).length;
|
||||||
|
const busiest = techs.slice().sort((a, b) => b.open - a.open)[0];
|
||||||
|
const top = (wl.statuses || [])[0];
|
||||||
|
|
||||||
|
const tile = (label, value, sub, accent) => `<div class="stat${accent ? ' accent' : ''}">
|
||||||
|
<div class="label">${esc(label)}</div>
|
||||||
|
<div class="value">${esc(value)}</div>
|
||||||
|
<div class="sub">${esc(sub || ' ')}</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const un = data.unassigned || {};
|
||||||
|
const unLoaded = !!un.updatedAt && !un.error;
|
||||||
|
|
||||||
|
return '<div class="stats">'
|
||||||
|
+ tile('Unassigned requests', unLoaded ? un.count : '–', unLoaded ? 'checked ' + clockTime(un.updatedAt) + (un.truncated ? ', partial' : '') : '')
|
||||||
|
+ tile('Open requests', loaded ? open : '–', top ? top.total + ' ' + top.name : '')
|
||||||
|
+ tile('Closed today', loaded ? closedToday : '–', 'since midnight', true)
|
||||||
|
+ tile('Closed last hour', lastHour, data.tickets[0] ? 'latest #' + data.tickets[0].id : 'none yet', true)
|
||||||
|
+ tile('Most open', busiest && busiest.open ? busiest.open : '–', busiest && busiest.open ? busiest.name : '')
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkload(wl) {
|
||||||
|
const statuses = wl.statuses || [];
|
||||||
|
const techs = wl.technicians || [];
|
||||||
|
|
||||||
|
const note = wl.updatedAt
|
||||||
|
? 'Updated ' + clockTime(wl.updatedAt) + (wl.truncated ? ' · partial: ' + wl.truncated : '')
|
||||||
|
: 'Loading…';
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if (!wl.updatedAt) {
|
||||||
|
body = '<div class="table-empty">Counting tickets…</div>';
|
||||||
|
} else if (!techs.length) {
|
||||||
|
body = '<div class="table-empty">No open tickets for the watched technicians.</div>';
|
||||||
|
} else {
|
||||||
|
const max = Math.max(1, ...techs.flatMap(t => statuses.map(s => t.counts[s.name] || 0)));
|
||||||
|
const cell = (n, cls) => n
|
||||||
|
? `<td class="num ${cls || ''}" style="--heat:${cls ? 0 : (n / max * 0.9 + 0.1).toFixed(3)}">${n}</td>`
|
||||||
|
: `<td class="num zero ${cls || ''}">·</td>`;
|
||||||
|
|
||||||
|
// totals come right after the name so they stay visible when the status columns scroll off
|
||||||
|
const head = '<tr><th scope="col">Technician</th>'
|
||||||
|
+ '<th scope="col" class="total">Total open</th><th scope="col" class="closed">Closed today</th>'
|
||||||
|
+ statuses.map(s => `<th scope="col"${s.color ? ` style="--status-color:${s.color}"` : ''}><span class="swatch"></span>${esc(s.name)}</th>`).join('')
|
||||||
|
+ '</tr>';
|
||||||
|
|
||||||
|
const rows = techs.map(t => `<tr${flashTechs.has(t.name) ? ' class="flash"' : ''}>
|
||||||
|
<td>${esc(t.name)}</td>
|
||||||
|
${cell(t.open, 'total')}
|
||||||
|
${cell(t.closedToday, 'closed')}
|
||||||
|
${statuses.map(s => cell(t.counts[s.name] || 0)).join('')}
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
const sum = key => techs.reduce((n, t) => n + t[key], 0);
|
||||||
|
const foot = '<tr><td>All technicians</td>'
|
||||||
|
+ `<td class="total">${sum('open')}</td><td class="closed">${sum('closedToday')}</td>`
|
||||||
|
+ statuses.map(s => `<td>${s.total}</td>`).join('')
|
||||||
|
+ '</tr>';
|
||||||
|
|
||||||
|
body = `<div class="table-scroll"><table>
|
||||||
|
<thead>${head}</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
<tfoot>${foot}</tfoot>
|
||||||
|
</table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<section class="section">
|
||||||
|
<div class="section-head"><h2>Tickets by technician</h2><span class="note">${esc(note)}</span></div>
|
||||||
|
${body}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconUrl = (code, size) => `https://openweathermap.org/img/wn/${code}@${size}x.png`;
|
||||||
|
|
||||||
|
function renderWeather() {
|
||||||
|
if (!weather || !weather.enabled) return '';
|
||||||
|
|
||||||
|
if (weather.temp === undefined) {
|
||||||
|
return `<section class="section">
|
||||||
|
<div class="section-head"><h2>Weather</h2></div>
|
||||||
|
<div class="table-empty">${weather.error ? 'Weather unavailable: ' + esc(weather.error) : 'Loading…'}</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD in local time
|
||||||
|
const days = (weather.forecast || []).map(d => `<div class="forecast-day">
|
||||||
|
<div class="name">${d.date === today ? 'Today' : new Date(d.date + 'T12:00').toLocaleDateString([], { weekday: 'short' })}</div>
|
||||||
|
${d.icon ? `<img src="${iconUrl(d.icon, 2)}" alt="${esc(d.condition || '')}">` : ''}
|
||||||
|
<div>${d.high}° <span class="lo">${d.low}°</span></div>
|
||||||
|
<div class="pop">${d.pop >= 20 ? d.pop + '%' : ' '}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const note = (weather.error ? 'stale · ' : '') + 'as of ' + clockTime(weather.updatedAt);
|
||||||
|
|
||||||
|
return `<section class="section">
|
||||||
|
<div class="section-head"><h2>${esc(weather.place || 'Weather')}</h2><span class="note">${esc(note)}</span></div>
|
||||||
|
<div class="weather-now">
|
||||||
|
${weather.icon ? `<img src="${iconUrl(weather.icon, 4)}" alt="">` : '<span></span>'}
|
||||||
|
<div>
|
||||||
|
<div class="weather-temp">${weather.temp}°F</div>
|
||||||
|
<div class="weather-desc">${esc(weather.description || weather.condition || '')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="weather-details">
|
||||||
|
<span>Feels <b>${weather.feelsLike}°</b></span>
|
||||||
|
<span>Wind <b>${weather.wind} mph</b></span>
|
||||||
|
<span>Humidity <b>${weather.humidity}%</b></span>
|
||||||
|
${weather.sunset ? `<span>Sunset <b>${clockTime(weather.sunset)}</b></span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${days ? `<div class="forecast">${days}</div>` : ''}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug list for comparing against the ManageEngine "Central Region Unassigned" view.
|
||||||
|
// Verified matching on 2026-10-15; flip to true to bring it back.
|
||||||
|
const SHOW_UNASSIGNED_LIST = false;
|
||||||
|
|
||||||
|
function renderUnassignedList(un) {
|
||||||
|
if (!SHOW_UNASSIGNED_LIST || !un.updatedAt || un.error) return '';
|
||||||
|
|
||||||
|
const rows = (un.tickets || []).map(t => `<tr>
|
||||||
|
<td class="num">#${esc(t.id)}</td>
|
||||||
|
<td class="subject-cell">${esc(t.subject || 'No subject')}</td>
|
||||||
|
<td>${esc(t.status || '')}</td>
|
||||||
|
<td>${esc(t.site || '')}</td>
|
||||||
|
<td>${esc(t.group || '')}</td>
|
||||||
|
<td>${esc(t.created || '')}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
const warn = un.discarded
|
||||||
|
? `<div class="warn">The API returned ${un.discarded} extra request(s) that failed the local check, so the search criteria aren't matching the view exactly.</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
firstLoad = false;
|
return `<details class="verify">
|
||||||
|
<summary>Verify: ${un.count} unassigned request${un.count === 1 ? '' : 's'} (temporary, click to expand)</summary>
|
||||||
|
${warn}
|
||||||
|
${rows
|
||||||
|
? `<div class="table-scroll"><table>
|
||||||
|
<thead><tr><th>ID</th><th>Subject</th><th>Status</th><th>Site</th><th>Group</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table></div>`
|
||||||
|
: '<div class="table-empty">No unassigned requests.</div>'}
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeed() {
|
||||||
|
const list = data.tickets.length
|
||||||
|
? '<div class="feed">' + data.tickets.map(renderTicket).join('') + '</div>'
|
||||||
|
: '<div class="empty"><p>Nothing closed yet.</p></div>';
|
||||||
|
|
||||||
|
return `<section class="section">
|
||||||
|
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.windowMinutes} min, kept 12h</span></div>
|
||||||
|
${list}
|
||||||
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTicket(ticket) {
|
function renderTicket(ticket) {
|
||||||
@ -440,7 +875,6 @@ function renderTicket(ticket) {
|
|||||||
// 0 = just closed, 1 = fully aged out (one hour on)
|
// 0 = just closed, 1 = fully aged out (one hour on)
|
||||||
const fade = Math.min(age / (60 * 60 * 1000), 1).toFixed(3);
|
const fade = Math.min(age / (60 * 60 * 1000), 1).toFixed(3);
|
||||||
const isNew = !firstLoad && !seen.has(ticket.id);
|
const isNew = !firstLoad && !seen.has(ticket.id);
|
||||||
seen.add(ticket.id);
|
|
||||||
|
|
||||||
return `<article class="ticket${isNew ? ' new' : ''}" style="--age:${fade}">
|
return `<article class="ticket${isNew ? ' new' : ''}" style="--age:${fade}">
|
||||||
<div class="rail">
|
<div class="rail">
|
||||||
@ -458,18 +892,40 @@ async function connect(btn) {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Connecting...';
|
btn.textContent = 'Connecting...';
|
||||||
await fetch('/api/connect', { method: 'POST' });
|
await fetch('/api/connect', { method: 'POST' });
|
||||||
load();
|
// check quickly until the device code arrives instead of waiting on the 5s poll
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await load();
|
||||||
|
if (!data || data.auth !== 'pending' || data.userCode) break;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkNow(btn) {
|
async function checkNow(btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Checking...';
|
btn.textContent = 'Refreshing...';
|
||||||
await fetch('/api/check', { method: 'POST' });
|
try {
|
||||||
|
await fetch('/api/check', { method: 'POST' });
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Refresh now';
|
||||||
|
}
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let weather = null;
|
||||||
|
|
||||||
|
async function loadWeather() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/weather');
|
||||||
|
weather = await res.json();
|
||||||
|
render();
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
|
loadWeather();
|
||||||
setInterval(load, 5000); // pull fresh state from the server
|
setInterval(load, 5000); // pull fresh state from the server
|
||||||
|
setInterval(loadWeather, 10 * 60 * 1000); // server caches for 10 min, no point asking sooner
|
||||||
setInterval(render, 20000); // re-age the rails between pulls
|
setInterval(render, 20000); // re-age the rails between pulls
|
||||||
</script>
|
</script>
|
||||||
<footer>
|
<footer>
|
||||||
@ -477,4 +933,3 @@ setInterval(render, 20000); // re-age the rails between pulls
|
|||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
function createRoutes(boardService) {
|
function createRoutes(boardService, weatherService) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/weather', async (req, res) => {
|
||||||
|
res.json(await weatherService.getWeather());
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/state', (req, res) => {
|
router.get('/state', (req, res) => {
|
||||||
res.json(boardService.getState());
|
res.json(boardService.getState());
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,8 +5,9 @@ const path = require('path');
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { createBoardService } = require('./boardService');
|
const { createBoardService } = require('./boardService');
|
||||||
const { createRoutes } = require('./routes');
|
const { createRoutes } = require('./routes');
|
||||||
|
const { createWeatherService } = require('./weather');
|
||||||
|
|
||||||
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env;
|
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env;
|
||||||
const PORT = process.env.PORT || 3000; // default 3000
|
const PORT = process.env.PORT || 3000; // default 3000
|
||||||
|
|
||||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||||
@ -14,12 +15,23 @@ if (!CLIENT_ID || !CLIENT_SECRET) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const technicians = (TECHNICIAN_NAMES ? TECHNICIAN_NAMES.split(',') : []).map(t => t.trim()).filter(Boolean);
|
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
|
||||||
const statuses = (STATUS_NAMES ? STATUS_NAMES.split(',') : []).map(s => s.trim()).filter(Boolean);
|
|
||||||
|
const technicians = toList(TECHNICIAN_NAMES);
|
||||||
|
const statuses = toList(STATUS_NAMES);
|
||||||
const statusSet = new Set(statuses.map(s => s.toLowerCase()));
|
const statusSet = new Set(statuses.map(s => s.toLowerCase()));
|
||||||
|
// statuses that count as done; everything else shows up in the workload table
|
||||||
|
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
|
||||||
|
// statuses the unassigned counter ignores, to match the ManageEngine view
|
||||||
|
const unassignedExcludedStatuses = process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
|
||||||
|
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
|
||||||
|
: closedStatuses;
|
||||||
|
// restricts every query to these sites/groups (the region); empty means no restriction
|
||||||
|
const scope = { sites: toList(SITE_NAMES), groups: toList(GROUP_NAMES) };
|
||||||
|
|
||||||
const WINDOW_MINUTES = 10; // how far back the server looks in minutes
|
const WINDOW_MINUTES = 10; // how far back the server looks in minutes
|
||||||
const CHECK_MINUTES = 1; // how often the server checks for new tickets in minutes
|
const CHECK_MINUTES = 1; // how often the server checks for new tickets in minutes
|
||||||
|
const WORKLOAD_MINUTES = Number(process.env.WORKLOAD_MINUTES) || 5; // how often the status table is recounted in minutes
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@ -29,13 +41,20 @@ const boardService = createBoardService({
|
|||||||
clientSecret: CLIENT_SECRET,
|
clientSecret: CLIENT_SECRET,
|
||||||
technicians,
|
technicians,
|
||||||
statusSet,
|
statusSet,
|
||||||
|
closedStatuses,
|
||||||
|
unassignedExcludedStatuses,
|
||||||
|
scope,
|
||||||
windowMinutes: WINDOW_MINUTES,
|
windowMinutes: WINDOW_MINUTES,
|
||||||
checkMinutes: CHECK_MINUTES
|
checkMinutes: CHECK_MINUTES,
|
||||||
|
workloadMinutes: WORKLOAD_MINUTES
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
app.use('/api', createRoutes(boardService));
|
// weather widget is optional; it stays hidden unless both values are set
|
||||||
|
const weatherService = createWeatherService({ apiKey: OWM_API_KEY, zipCode: OWM_ZIP_CODE });
|
||||||
|
|
||||||
|
app.use('/api', createRoutes(boardService, weatherService));
|
||||||
|
|
||||||
//starts listening on port specified
|
//starts listening on port specified
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|||||||
290
src/tickets.js
290
src/tickets.js
@ -1,5 +1,9 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_URL = 'https://ithelp.mainecc.edu/api/v3/requests';
|
||||||
|
const PAGE_SIZE = 100; // SDP caps row_count at 100 per page
|
||||||
|
const MAX_PAGES = 20; // safety net so a bad filter can't page forever
|
||||||
|
|
||||||
function toMillis(completedTime) {
|
function toMillis(completedTime) {
|
||||||
if (!completedTime) return null;
|
if (!completedTime) return null;
|
||||||
const raw = typeof completedTime === 'object' ? completedTime.value : completedTime;
|
const raw = typeof completedTime === 'object' ? completedTime.value : completedTime;
|
||||||
@ -14,47 +18,121 @@ function getRelevantTicketTime(ticket) {
|
|||||||
|| null;
|
|| null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, windowMinutes }) {
|
function describeError(err) {
|
||||||
|
const detail = err.response?.data;
|
||||||
|
console.error('Error: ', detail || err.message);
|
||||||
|
return detail?.response_status?.messages?.[0]?.message
|
||||||
|
|| (typeof detail === 'string' ? detail : null)
|
||||||
|
|| err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// joins search criteria with AND, skipping any that are null
|
||||||
|
function allOf(...criteria) {
|
||||||
|
return criteria.flat().filter(Boolean).map((c, i) => (i ? { ...c, logical_operator: 'AND' } : c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// limits a query to the configured sites/groups (the region)
|
||||||
|
function scopeCriteria(scope) {
|
||||||
|
return [
|
||||||
|
scope.sites.length ? { field: 'site.name', condition: 'is', values: scope.sites } : null,
|
||||||
|
scope.groups.length ? { field: 'group.name', condition: 'is', values: scope.groups } : null
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// same check done locally, in case the API ever returns something outside the region
|
||||||
|
function inScope(ticket, scope) {
|
||||||
|
const matches = (list, name) => !list.length
|
||||||
|
|| (!!name && list.some(item => item.toLowerCase() === name.toLowerCase()));
|
||||||
|
return matches(scope.sites, ticket.site?.name) && matches(scope.groups, ticket.group?.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRequests(accessToken, listInfo) {
|
||||||
|
const response = await axios.get(API_URL, {
|
||||||
|
params: { input_data: JSON.stringify({ list_info: listInfo }) },
|
||||||
|
headers: {
|
||||||
|
Authorization: `Zoho-oauthtoken ${accessToken}`,
|
||||||
|
Accept: 'application/vnd.manageengine.sdp.v3+json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.data || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllRequests(accessToken, listInfo, label) {
|
||||||
|
const byId = new Map();
|
||||||
|
let totalCount = null;
|
||||||
|
let pages = 0;
|
||||||
|
let duplicates = 0;
|
||||||
|
let truncated = true;
|
||||||
|
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
const data = await getRequests(accessToken, {
|
||||||
|
...listInfo,
|
||||||
|
row_count: PAGE_SIZE,
|
||||||
|
start_index: page * PAGE_SIZE + 1,
|
||||||
|
get_total_count: page === 0
|
||||||
|
});
|
||||||
|
pages++;
|
||||||
|
if (page === 0) totalCount = data.list_info?.total_count ?? null;
|
||||||
|
|
||||||
|
// dedupe by id so a page that repeats (or shifts as tickets change) can't double count
|
||||||
|
let added = 0;
|
||||||
|
for (const ticket of data.requests || []) {
|
||||||
|
const id = String(ticket.id ?? ticket.display_id);
|
||||||
|
if (byId.has(id)) duplicates++;
|
||||||
|
else { byId.set(id, ticket); added++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.list_info?.has_more_rows) { truncated = false; break; }
|
||||||
|
// more matches than we could ever page through almost always means the API ignored our
|
||||||
|
// search criteria; don't spend 19 more calls downloading the wrong data
|
||||||
|
if (totalCount > PAGE_SIZE * MAX_PAGES) break;
|
||||||
|
// a full page of repeats means start_index isn't moving us forward; stop rather than loop
|
||||||
|
if (!added) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = [...byId.values()];
|
||||||
|
if (truncated || duplicates) {
|
||||||
|
console.warn(`[${label}] pages=${pages} unique=${requests.length} duplicates=${duplicates} total_count=${totalCount} truncated=${truncated}`);
|
||||||
|
}
|
||||||
|
return { requests, truncated, totalCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, scope, windowMinutes }) {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const windowStart = Date.now() - windowMinutes * 60 * 1000;
|
const windowStart = Date.now() - windowMinutes * 60 * 1000;
|
||||||
|
|
||||||
const listInfo = {
|
const data = await getRequests(accessToken, {
|
||||||
list_info: {
|
row_count: 200,
|
||||||
row_count: 200,
|
sort_field: 'last_updated_time',
|
||||||
sort_field: 'last_updated_time',
|
sort_order: 'desc',
|
||||||
sort_order: 'desc',
|
fields_required: [
|
||||||
fields_required: [
|
'display_id',
|
||||||
'display_id',
|
'subject',
|
||||||
'subject',
|
'status',
|
||||||
'status',
|
'completed_time',
|
||||||
'completed_time',
|
'resolved_time',
|
||||||
'resolved_time',
|
'last_updated_time',
|
||||||
'last_updated_time',
|
'technician',
|
||||||
'technician'
|
'site',
|
||||||
],
|
'group'
|
||||||
search_criteria: [
|
],
|
||||||
{
|
search_criteria: allOf(
|
||||||
field: 'last_updated_time',
|
{
|
||||||
condition: 'greater_than',
|
field: 'last_updated_time',
|
||||||
value: windowStart.toString()
|
condition: 'greater than',
|
||||||
}
|
value: windowStart.toString()
|
||||||
]
|
},
|
||||||
}
|
scopeCriteria(scope)
|
||||||
};
|
)
|
||||||
|
|
||||||
const response = await axios.get('https://ithelp.mainecc.edu/api/v3/requests', {
|
|
||||||
params: { input_data: JSON.stringify(listInfo) },
|
|
||||||
headers: {
|
|
||||||
Authorization: `Zoho-oauthtoken ${accessToken}`,
|
|
||||||
Accept: 'application/vnd.manageengine.sdp.v3+json'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const found = response.data?.requests || [];
|
const found = data.requests || [];
|
||||||
|
|
||||||
for (const ticket of found) {
|
for (const ticket of found) {
|
||||||
|
if (!inScope(ticket, scope)) continue;
|
||||||
|
|
||||||
const statusName = ticket.status?.name || '';
|
const statusName = ticket.status?.name || '';
|
||||||
const statusInternalName = ticket.status?.internal_name || '';
|
const statusInternalName = ticket.status?.internal_name || '';
|
||||||
const statusMatches = !statusSet.size
|
const statusMatches = !statusSet.size
|
||||||
@ -90,15 +168,149 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
|
|||||||
state.lastCheck = Date.now();
|
state.lastCheck = Date.now();
|
||||||
state.lastError = null;
|
state.lastError = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const detail = err.response?.data;
|
state.lastError = describeError(err);
|
||||||
state.lastError = detail?.response_status?.messages?.[0]?.message
|
|
||||||
|| (typeof detail === 'string' ? detail : null)
|
|
||||||
|| err.message;
|
|
||||||
state.lastCheck = Date.now();
|
state.lastCheck = Date.now();
|
||||||
console.error('Error: ', detail || err.message);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts every open ticket by technician and status, plus how many each
|
||||||
|
// technician has closed since midnight.
|
||||||
|
async function fetchWorkload({ accessToken, workload, technicians, closedStatuses, scope }) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
|
||||||
|
const startOfDay = new Date();
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const closedLower = new Set(closedStatuses.map(s => s.toLowerCase()));
|
||||||
|
const byLowerName = new Map(technicians.map(t => [t.toLowerCase(), t]));
|
||||||
|
const techCriterion = technicians.length
|
||||||
|
? { field: 'technician.name', condition: 'is', values: technicians }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// maps a ticket to the row it's counted under, or null to skip it
|
||||||
|
function rowFor(ticket) {
|
||||||
|
if (!inScope(ticket, scope)) return null;
|
||||||
|
const name = ticket.technician?.name;
|
||||||
|
if (!technicians.length) return name || 'Unassigned';
|
||||||
|
return name ? byLowerName.get(name.toLowerCase()) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const open = await getAllRequests(accessToken, {
|
||||||
|
sort_field: 'created_time',
|
||||||
|
sort_order: 'desc',
|
||||||
|
fields_required: ['display_id', 'status', 'technician', 'site', 'group'],
|
||||||
|
search_criteria: allOf(
|
||||||
|
{ field: 'status.name', condition: 'is not', values: closedStatuses },
|
||||||
|
techCriterion,
|
||||||
|
scopeCriteria(scope)
|
||||||
|
)
|
||||||
|
}, 'open tickets');
|
||||||
|
|
||||||
|
const closed = await getAllRequests(accessToken, {
|
||||||
|
sort_field: 'last_updated_time',
|
||||||
|
sort_order: 'desc',
|
||||||
|
fields_required: ['display_id', 'status', 'technician', 'site', 'group', 'completed_time', 'resolved_time', 'last_updated_time'],
|
||||||
|
search_criteria: allOf(
|
||||||
|
{ field: 'last_updated_time', condition: 'greater than', value: startOfDay.getTime().toString() },
|
||||||
|
{ field: 'status.name', condition: 'is', values: closedStatuses },
|
||||||
|
techCriterion,
|
||||||
|
scopeCriteria(scope)
|
||||||
|
)
|
||||||
|
}, 'closed today');
|
||||||
|
|
||||||
|
const rows = new Map(technicians.map(t => [t, { name: t, counts: {}, open: 0, closedToday: 0 }]));
|
||||||
|
const row = name => {
|
||||||
|
if (!rows.has(name)) rows.set(name, { name, counts: {}, open: 0, closedToday: 0 });
|
||||||
|
return rows.get(name);
|
||||||
|
};
|
||||||
|
const statuses = new Map();
|
||||||
|
|
||||||
|
for (const ticket of open.requests) {
|
||||||
|
const status = ticket.status?.name;
|
||||||
|
if (!status || closedLower.has(status.toLowerCase())) continue;
|
||||||
|
const tech = rowFor(ticket);
|
||||||
|
if (!tech) continue;
|
||||||
|
|
||||||
|
const r = row(tech);
|
||||||
|
r.counts[status] = (r.counts[status] || 0) + 1;
|
||||||
|
r.open++;
|
||||||
|
|
||||||
|
if (!statuses.has(status)) {
|
||||||
|
const color = /^#[0-9a-f]{3,8}$/i.test(ticket.status.color || '') ? ticket.status.color : null;
|
||||||
|
statuses.set(status, { name: status, color, total: 0 });
|
||||||
|
}
|
||||||
|
statuses.get(status).total++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ticket of closed.requests) {
|
||||||
|
const closedAt = getRelevantTicketTime(ticket);
|
||||||
|
if (!closedAt || closedAt < startOfDay.getTime()) continue;
|
||||||
|
const tech = rowFor(ticket);
|
||||||
|
if (tech) row(tech).closedToday++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open first, then busiest statuses
|
||||||
|
workload.statuses = [...statuses.values()].sort((a, b) =>
|
||||||
|
(b.name === 'Open') - (a.name === 'Open') || b.total - a.total || a.name.localeCompare(b.name));
|
||||||
|
workload.technicians = [...rows.values()];
|
||||||
|
if (!technicians.length) workload.technicians.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
// which query hit the page cap, and how many tickets SDP says matched, so the page can explain it
|
||||||
|
const cutOff = (label, result) => result.truncated
|
||||||
|
&& `${label} (${result.requests.length} of ${result.totalCount ?? '?'})`;
|
||||||
|
workload.truncated = [cutOff('open tickets', open), cutOff('closed today', closed)]
|
||||||
|
.filter(Boolean).join(', ') || false;
|
||||||
|
workload.updatedAt = Date.now();
|
||||||
|
workload.error = null;
|
||||||
|
} catch (err) {
|
||||||
|
workload.error = describeError(err);
|
||||||
|
workload.updatedAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the "Central Region Unassigned" view: in-scope, not excluded, no technician.
|
||||||
|
async function fetchUnassigned({ accessToken, unassigned, excludedStatuses, scope }) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
|
||||||
|
const excludedLower = new Set(excludedStatuses.map(s => s.toLowerCase()));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAllRequests(accessToken, {
|
||||||
|
sort_field: 'created_time',
|
||||||
|
sort_order: 'desc',
|
||||||
|
fields_required: ['display_id', 'subject', 'status', 'technician', 'site', 'group', 'created_time'],
|
||||||
|
search_criteria: allOf(
|
||||||
|
scopeCriteria(scope),
|
||||||
|
excludedStatuses.length ? { field: 'status.name', condition: 'is not', values: excludedStatuses } : null,
|
||||||
|
// "is empty": the API documents "technician is not empty" as condition 'is not' with no value
|
||||||
|
{ field: 'technician', condition: 'is' }
|
||||||
|
)
|
||||||
|
}, 'unassigned');
|
||||||
|
|
||||||
|
unassigned.tickets = result.requests
|
||||||
|
.filter(t => !t.technician && inScope(t, scope) && !excludedLower.has((t.status?.name || '').toLowerCase()))
|
||||||
|
.map(t => ({
|
||||||
|
id: t.display_id,
|
||||||
|
subject: t.subject,
|
||||||
|
status: t.status?.name || null,
|
||||||
|
site: t.site?.name || null,
|
||||||
|
group: t.group?.name || null,
|
||||||
|
created: t.created_time?.display_value || null
|
||||||
|
}));
|
||||||
|
unassigned.count = unassigned.tickets.length;
|
||||||
|
// how many the API returned that we threw out locally; non-zero means the criteria are off
|
||||||
|
unassigned.discarded = result.requests.length - unassigned.count;
|
||||||
|
unassigned.truncated = result.truncated;
|
||||||
|
unassigned.updatedAt = Date.now();
|
||||||
|
unassigned.error = null;
|
||||||
|
} catch (err) {
|
||||||
|
unassigned.error = describeError(err);
|
||||||
|
unassigned.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fetchClosedTickets
|
fetchClosedTickets,
|
||||||
};
|
fetchWorkload,
|
||||||
|
fetchUnassigned
|
||||||
|
};
|
||||||
|
|||||||
107
src/weather.js
Normal file
107
src/weather.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_URL = 'https://api.openweathermap.org/data/2.5';
|
||||||
|
const CACHE_MINUTES = 10; // OpenWeatherMap only updates about every 10 minutes anyway
|
||||||
|
const FORECAST_DAYS = 5;
|
||||||
|
|
||||||
|
// only pass through icon codes like "10d" so the page can build the image URL safely
|
||||||
|
function iconCode(icon) {
|
||||||
|
return /^\d\d[dn]$/.test(icon || '') ? icon : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the 5 day forecast comes in 3 hour slots; roll them up into one entry per local day
|
||||||
|
function dailyForecast(forecast) {
|
||||||
|
const offset = (forecast.city?.timezone || 0) * 1000;
|
||||||
|
const days = new Map();
|
||||||
|
|
||||||
|
for (const slot of forecast.list || []) {
|
||||||
|
const local = new Date(slot.dt * 1000 + offset);
|
||||||
|
const key = local.toISOString().slice(0, 10);
|
||||||
|
if (!days.has(key)) days.set(key, { date: key, low: Infinity, high: -Infinity, pop: 0, noon: null });
|
||||||
|
|
||||||
|
const day = days.get(key);
|
||||||
|
day.low = Math.min(day.low, slot.main.temp_min);
|
||||||
|
day.high = Math.max(day.high, slot.main.temp_max);
|
||||||
|
day.pop = Math.max(day.pop, slot.pop || 0);
|
||||||
|
|
||||||
|
// the slot nearest midday best represents the day's conditions
|
||||||
|
const distance = Math.abs(local.getUTCHours() - 12);
|
||||||
|
if (!day.noon || distance < day.noon.distance) day.noon = { distance, weather: slot.weather?.[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...days.values()].slice(0, FORECAST_DAYS).map(day => ({
|
||||||
|
date: day.date,
|
||||||
|
low: Math.round(day.low),
|
||||||
|
high: Math.round(day.high),
|
||||||
|
pop: Math.round(day.pop * 100),
|
||||||
|
condition: day.noon?.weather?.main || null,
|
||||||
|
icon: iconCode(day.noon?.weather?.icon)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWeatherService({ apiKey, zipCode }) {
|
||||||
|
const enabled = !!(apiKey && zipCode);
|
||||||
|
// US zip by default; "12345,ca" style values pass through untouched
|
||||||
|
const zip = zipCode && zipCode.includes(',') ? zipCode : `${zipCode},us`;
|
||||||
|
|
||||||
|
let cache = null;
|
||||||
|
let fetchedAt = 0;
|
||||||
|
let pending = null;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
async function fetchWeather() {
|
||||||
|
const params = { zip, units: 'imperial', appid: apiKey };
|
||||||
|
const [current, forecast] = await Promise.all([
|
||||||
|
axios.get(`${API_URL}/weather`, { params }),
|
||||||
|
axios.get(`${API_URL}/forecast`, { params })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const c = current.data;
|
||||||
|
return {
|
||||||
|
place: c.name,
|
||||||
|
updatedAt: c.dt * 1000,
|
||||||
|
temp: Math.round(c.main.temp),
|
||||||
|
feelsLike: Math.round(c.main.feels_like),
|
||||||
|
high: Math.round(c.main.temp_max),
|
||||||
|
low: Math.round(c.main.temp_min),
|
||||||
|
humidity: c.main.humidity,
|
||||||
|
wind: Math.round(c.wind?.speed || 0),
|
||||||
|
condition: c.weather?.[0]?.main || null,
|
||||||
|
description: c.weather?.[0]?.description || null,
|
||||||
|
icon: iconCode(c.weather?.[0]?.icon),
|
||||||
|
sunrise: c.sys?.sunrise ? c.sys.sunrise * 1000 : null,
|
||||||
|
sunset: c.sys?.sunset ? c.sys.sunset * 1000 : null,
|
||||||
|
forecast: dailyForecast(forecast.data)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWeather() {
|
||||||
|
if (!enabled) return { enabled: false };
|
||||||
|
|
||||||
|
if (Date.now() - fetchedAt > CACHE_MINUTES * 60 * 1000) {
|
||||||
|
// share one request between callers that arrive while it's in flight
|
||||||
|
pending = pending || fetchWeather()
|
||||||
|
.then(data => {
|
||||||
|
cache = data;
|
||||||
|
lastError = null;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
lastError = err.response?.data?.message || err.message;
|
||||||
|
console.error('Weather error: ', lastError);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
fetchedAt = Date.now();
|
||||||
|
pending = null;
|
||||||
|
});
|
||||||
|
await pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enabled: true, error: lastError, ...cache };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getWeather };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createWeatherService
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user