Slopify This Thang

This commit is contained in:
Skylar Grant 2026-10-15 15:23:05 -04:00
parent 3e592f9a0d
commit 8c7b5fbd26
13 changed files with 1100 additions and 174 deletions

101
README.md
View File

@ -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 ![Dashboard](images/4.png)
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:
![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 <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**.
![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 ## 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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 73 KiB

24
package-lock.json generated
View File

@ -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"
}, },

View File

@ -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": {}
} }

View File

@ -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 {

View File

@ -2,3 +2,14 @@ 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

View File

@ -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) {
html += `<div class="error"><strong>Status counts failed</strong>${esc(wl.error)}</div>`;
}
if (data.unassigned && data.unassigned.error) {
html += `<div class="error"><strong>Unassigned count failed</strong>${esc(data.unassigned.error)}</div>`;
}
if (data.auth === 'connected') { if (data.auth === 'connected') {
const tallies = {}; html += renderStats(wl);
for (const t of data.tickets) tallies[t.technician] = (tallies[t.technician] || 0) + 1; html += `<div class="board">
const names = Object.keys(tallies).sort((a, b) => tallies[b] - tallies[a]); ${renderWorkload(wl)}
<div class="side">
if (names.length) { ${renderWeather()}
html += '<div class="tallies">' + names.map(n => ${renderFeed()}
`<span class="tally">${esc(n)} <b>${tallies[n]}</b></span>`).join('') + '</div>'; </div>
} </div>`;
html += renderUnassignedList(data.unassigned || {});
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>';
} }
// keep the verification list open across the 5s re-renders
const verifyOpen = !!document.querySelector('.verify[open]');
$('main').innerHTML = html; $('main').innerHTML = html;
if (verifyOpen) document.querySelector('.verify').open = true;
$('footer').textContent = data.tickets && data.tickets.length for (const t of data.tickets) seen.add(t.id);
? data.tickets.length + ' tickets on the board' 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 + '%' : '&nbsp;'}</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...';
try {
await fetch('/api/check', { method: 'POST' }); 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>

View File

@ -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());
}); });

View File

@ -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, () => {

View File

@ -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,14 +18,92 @@ 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',
@ -32,29 +114,25 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
'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', field: 'last_updated_time',
condition: 'greater_than', condition: 'greater than',
value: windowStart.toString() 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
View 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
};