Compare commits

...

4 Commits
main ... v1.1.0

Author SHA1 Message Date
3e79a24efd . 2026-10-15 19:07:07 -04:00
1e3f8dc1ad . 2026-10-15 16:53:51 -04:00
c75b5889cf Update credits and licensing 2026-10-15 15:38:59 -04:00
8c7b5fbd26 Slopify This Thang 2026-10-15 15:23:05 -04:00
21 changed files with 1556 additions and 193 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ lerna-debug.log*
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
data/
pids
*.pid
*.seed

40
CLAUDE.md Normal file
View File

@ -0,0 +1,40 @@
# CLAUDE.md
Context for Claude Code sessions in this repo. Keep this short. Longer notes live in `docs/`.
## Project
A help desk dashboard for ManageEngine ServiceDesk Plus Cloud (`ithelp.mainecc.edu`). It currently runs as a single Node/Express server. The central region (KVCC, EMCC, CMCC, MCCS) is configured through `.env`.
- **Goal:** multi-region, multi-user, customisable. See `docs/ROADMAP.md`.
- **Decisions, API gotchas and known issues:** `docs/NOTES.md`. Read it before touching any SDP query.
- **Latest session summary and handoff:** `SESSION.md`.
## Run
```text
npm install
npm run app # node src/server.js from the repo root; .env lives in the repo root
```
The app is served at <http://localhost:3000>. Sign in with the Connect button, which uses Zoho's device-code flow.
## Layout
- `src/server.js`: reads `.env` and starts everything.
- `src/auth.js`: Zoho device-code sign-in.
- `src/boardService.js`: token, state and refresh timers.
- `src/tickets.js`: all SDP queries (closed feed, status table, closed today, unassigned).
- `src/weather.js`: OpenWeatherMap, cached for 10 minutes.
- `src/routes.js`: the `/api/*` routes.
- `src/public/index.html`: the whole front end in one file (inline CSS and JS, no build step).
## Working agreements
- Never print or commit `.env` values (Zoho client secret, OpenWeatherMap key). Redact them when inspecting.
- There are no automated tests yet. Verify server logic by stubbing `axios.get`. Resolve axios from the project with `require.resolve('axios', { paths: [dir] })` so the stub hits the same instance. Verify the page by evaluating its `<script>` with stubbed `document` and `fetch`, or by taking a screenshot with headless Edge.
- The sandbox can't start a second server instance; it can't bind ports. Screenshots of the live app use the user's running server on port 3000.
- Match the existing style: 4-space indent and CommonJS on the server, 2-space indent in `index.html`, lowercase `//` comments explaining why rather than what. Some files have CRLF line endings, so use the Edit tool rather than sed for multi-line changes.
- Keep the core closed-ticket watcher (feed and ding) working and on by default.
- Don't commit unless asked. Commit messages end with the Co-Authored-By line for Claude.
- Record new decisions and gotchas in `docs/NOTES.md`, and move roadmap items as they're done.

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 sgrant
Copyright (c) 2026 Skylar Grant
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

114
README.md
View File

@ -1,44 +1,111 @@
# sdp-request-watcher
A local Node.js dashboard for ManageEngine ServiceDesk Plus (SDP) Cloud. It shows each technician's open tickets by status, plays a sound when a ticket is closed, tracks the unassigned queue, and shows local weather.
## Env and Server Setup
env.example contains the setup for the env requires a Non-Browser Application key from https://api-console.zoho.com/add
Place the .env file inside the src directory
![Dashboard](images/4.png)
Technicians and Statuses are checked in the .env file
## Setup
I resolved the issue with it only finding closed tickets so it should now find resolved and closed I havent tested with others but will
### 1. Zoho API client
The technician field can have spaces after the comma however the statuses can not. There can be no spaces before the status or it wont work properly.
Create a **Non-Browser Application** at <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
Open the site after the server is running, then connect the Zoho account from the board.
| Setting | Required | What it does |
| --- | --- | --- |
| `CLIENT_ID`, `CLIENT_SECRET` | Yes | The Zoho Non-Browser Application credentials. |
| `TECHNICIAN_NAMES` | No | Comma-separated technician names, spelled as they appear in SDP. One table row per name. Leave it empty to include every technician. |
| `STATUS_NAMES` | No | Statuses that count as "closed" for the ding and the Closed today count. Everything else counts as open. Defaults to `Closed,Resolved,Canceled`. |
| `SITE_NAMES` | No | Only include requests from these sites (the region). Leave it empty for all sites. |
| `GROUP_NAMES` | No | Only include requests in these support groups, e.g. `Customer Support`. Leave it empty for all groups. |
| `UNASSIGNED_EXCLUDED_STATUSES` | No | Statuses the Unassigned requests counter leaves out. Defaults to `STATUS_NAMES`. |
| `WORKLOAD_MINUTES` | No | How often the status table and unassigned count refresh, in minutes. Defaults to 5. |
| `ADMIN_KEY` | No | Key needed for **Connect** and **Refresh now**. Enter it with the page's **Admin** button; it's remembered in that browser. Without a key, only browsers on the server's own machine can use those buttons. Set one before hosting. |
| `TOKEN_FILE` | No | Where the Zoho sign-in is saved. Defaults to `data/token.json`, which git ignores. |
| `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
2. Open the local dashboard in your browser. Should be localhost:3000
3. Click **Connect** to begin the Zoho authorization flow.
4. Approve the device code on the Zoho page, then return to the dashboard.
Spaces around commas are fine in every list.
What you should see:
The central region settings currently in use:
![Site setup step 1](images/1.png)
```text
SITE_NAMES=Base Site,ASSIGN,CMCC,CMCC-Damariscotta,EMCC,KVCC,KVCC-Alfond,MCCS
GROUP_NAMES=Customer Support
UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved
```
![Site setup step 2](images/2.png)
These match the "Central Region Unassigned" custom view in ManageEngine. The Unassigned requests count was checked against that view and matched.
![Site setup step 3](images/3.png)
### 3. Run it
![Site setup step 4](images/4.png)
```text
npm install
npm run app
```
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.
`npm run app` runs `node src/server.js` from the project root. Then open <http://localhost:3000>. Set `PORT` in `.env` to use a different port.
## Connecting to Zoho
An admin connects once. The sign-in is saved to `data/token.json` and renewed automatically every hour, so the board keeps running and picks up again after a restart. You only connect again if Zoho revokes the sign-in; the page then shows the Connect button again. Treat `data/token.json` like a password.
1. Click **Connect**. If `ADMIN_KEY` is set, click **Admin** and enter it first.
![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, renews the token, and runs the refresh timers. |
| `src/tokenStore.js` | Saves the Zoho sign-in to disk so it survives restarts. |
| `src/tickets.js` | SDP API queries for the closed feed, the status table, closed today and unassigned requests. |
| `src/weather.js` | OpenWeatherMap current weather and forecast, cached for 10 minutes. |
| `src/routes.js` | `/api/state`, `/api/connect`, `/api/check` and `/api/weather`. |
| `src/public/index.html` | The dashboard page. |
Every SDP query is limited to the configured sites, groups and technicians. Results are also re-checked locally, so a ticket from outside the region is never counted even if the API returns one.
## Requirements
axios
dotenv
Node.js 18 or newer. Dependencies are `axios`, `dotenv` and `express`, all installed by `npm install`.
## Reference Materials/ Provided by Skylar
## Credits
- **Skylar Grant**: project owner and author.
- **Isaac Godin**: original page design.
- **Claude** (Anthropic, via Claude Code): dashboard, region scoping, unassigned counter, weather card and SDP query fixes, built with Skylar.
## Reference materials (provided by Skylar)
- [Non-Browser Apps - Overview](https://www.zoho.com/accounts/protocol/oauth/limited-input-devices.html)
- [Non-Browser Apps - Initiation Request](https://www.zoho.com/accounts/protocol/oauth/devices/initiation-request.html)
@ -46,3 +113,8 @@ dotenv
- [Get List Request](https://www.manageengine.com/products/service-desk/sdpod-v3-api/requests/request.html#get-list-request)
- [Input Data](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/input-data.html)
- [Search Criteria](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/search-criteria.html)
- [OpenWeatherMap Current Weather](https://openweathermap.org/current) and [5 Day Forecast](https://openweathermap.org/forecast5)
## License
MIT. See [LICENSE](LICENSE).

56
SESSION.md Normal file
View File

@ -0,0 +1,56 @@
# Session summary: 2026-10-15
A Claude Code session that turned the closed-ticket board into a dashboard. Background is in `CLAUDE.md`, `docs/ROADMAP.md` and `docs/NOTES.md`.
## Where things stand
The dashboard is running and working against live SDP data for the central region. All the work below is in the working tree. The last few files (`CLAUDE.md`, `docs/`, `SESSION.md`) may still be uncommitted.
## What was built, in order
1. **Dashboard redesign** (`index.html`, `tickets.js`, `boardService.js`): stat tiles; a technician × status table with Total open and Closed today columns; the live closed feed in a side column; a "Sound on close" toggle, on by default; Refresh now. The table refreshes every `WORKLOAD_MINUTES` (default 5). The closed watcher still checks every minute.
2. **Central region scoping:** `SITE_NAMES` and `GROUP_NAMES` in `.env` are applied to every SDP query, and each ticket is also re-checked locally.
3. **Unassigned requests counter:** `UNASSIGNED_EXCLUDED_STATUSES` mirrors the ManageEngine "Central Region Unassigned" view. **Verified to match the view exactly.** The debug list is kept but hidden (`SHOW_UNASSIGNED_LIST = false` in `index.html`).
4. **Weather card** (`src/weather.js`, `/api/weather`): OpenWeatherMap current weather and 5-day forecast with icons, using `OWM_API_KEY` and `OWM_ZIP_CODE`, cached for 10 minutes. Working.
5. **Sign-in changes:** the Zoho page opens in a new tab, the button only appears once the device code exists, and the page checks quickly after Connect is clicked.
6. **Table layout:** totals moved to the left so they don't get cut off; the auth panel text is wider.
7. **SDP query fixes:**
- `greater_than` → `greater than`. The wrong spelling made SDP ignore all the criteria and return about 124k tickets.
- Paging now dedupes by ID and stops early when the total can't be reached.
- The "partial" note now names which query was cut off.
- **Verified fixed:** the "partial" note is gone.
8. **Small fixes:** `[hidden]` CSS bug (Refresh now showed when disconnected); a misplaced comment in `server.js`.
9. **README rewritten**, with new screenshots in `images/`. `1.png` and `2.png` are rendered with sample data; `4.png` is from the live server; `3.png` is unchanged.
10. **Credits:** page footer, README, `package.json` contributors, `server.js` header. Isaac Godin keeps the design credit.
11. **License:** MIT (LICENSE copyright Skylar Grant; package.json now says MIT, not ISC).
12. **Repo structure for working with Claude:** `CLAUDE.md`, `docs/ROADMAP.md`, `docs/NOTES.md`.
## Settings added to `.env` this session
`SITE_NAMES`, `GROUP_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`, `WORKLOAD_MINUTES` (optional), `OWM_API_KEY`, `OWM_ZIP_CODE`. All are documented in `src/env.example` and the README.
## Known issues
- On a typical screen the status table scrolls sideways ("Need Info" gets cut off), because the page is capped at 1,320px.
- Anyone who can reach the URL can still *see* the data (Connect and Refresh are admin-only now). That's for the auth rebuild to solve.
## Phase 1 done (later the same day)
- `auth.js` keeps the `refresh_token` and adds `refreshAccessToken()`. `boardService.js` renews the access token 5 minutes before it expires (once, even when checks overlap), and `resume()` restarts from `data/token.json` at boot (`src/tokenStore.js`; `TOKEN_FILE` to move it). A revoked sign-in drops back to the Connect screen.
- `/api/connect` and `/api/check` are admin-only: `ADMIN_KEY` via the page's Admin button, or localhost only when no key is set. `/api/state` hides the device code from non-admins and reports `isAdmin`.
- Verified with stubbed axios and a stubbed page DOM, not yet against live Zoho. **To check live:** connect, restart the server, and confirm it comes back live without Connect. Then wait more than an hour and confirm "Access token renewed" appears in the log.
## Plan change
Phases 2 to 5 are dropped. The whole auth process is being rebuilt to run on a server instead of locally, and the roadmap will be redrawn around it. Don't start any of the old phases. Phase 1 (token renewal, saved sign-in, `ADMIN_KEY`) still works today, but the rebuild may replace it. See `docs/ROADMAP.md`.
## Next steps
1. **Check phase 1 against live Zoho** (steps above), then commit it so the board keeps running in the meantime.
2. **Plan the server-based auth rebuild** and rewrite the phases in `docs/ROADMAP.md` around it.
## To pick up
Start a Claude Code session in this repo and say something like:
*"Read SESSION.md and docs/ROADMAP.md. The old phases 2 to 5 are dropped, and I'm rebuilding auth to run on a server instead of locally. Help me plan that rebuild and redraw the roadmap around it."*

30
docs/NOTES.md Normal file
View File

@ -0,0 +1,30 @@
# Notes
## SDP v3 API gotchas
Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/search-criteria.html>
- **Condition names have spaces:** `greater than`, `is not`, `lesser than`. `greater_than` isn't valid, and SDP silently ignored the **entire** search criteria because of it, returning every ticket (124,050). If a query's `total_count` is huge, suspect the criteria first.
- **Paging:** `row_count` is at most 100. Use `start_index`, and stop when `list_info.has_more_rows` is false. Ask for `get_total_count: true` on the first page. `getAllRequests` dedupes by ID and stops at 20 pages or when the total is unreachable.
- **Multiple values:** use a `values: [...]` array, and join criteria with `logical_operator: 'AND'` (see `allOf()` in `tickets.js`).
- **"Technician is empty":** written as `{ field: 'technician', condition: 'is' }` with no value. This isn't documented, since the docs only show the "is not" form, but it's verified to work.
- **Field names that work in filters:** `site.name`, `group.name`, `technician.name`, `status.name`, `last_updated_time`.
- **Group names:** the ManageEngine UI shows groups as "Customer Support « KVCC", but the API name is plain "Customer Support".
- **Sign-in scope:** `SDPOnDemand.requests.READ` is enough for everything so far.
- **Token lifetime:** Zoho access tokens last about an hour. The device flow only returns a `refresh_token` because the device-code request asks for `access_type: offline`. Renew it with `POST accounts.zoho.com/oauth/v2/token`, `grant_type=refresh_token`.
- **Zoho errors come back as HTTP 200:** a rejected refresh token returns `{ "error": "invalid_code" }` with status 200, so check for `access_token` rather than relying on axios throwing. The device-token poll works the same way (`authorization_pending`).
## Decisions
- **2026-10-15:** **MIT license.** Copyright Skylar Grant.
- **2026-10-15:** **OpenWeatherMap** for weather. The free tier covers current weather and the 5-day forecast, and results are cached for 10 minutes. The National Weather Service API was the alternative (free, no key), but it needs three calls per lookup.
- **2026-10-15:** **Unassigned counter** uses its own status exclusions (`UNASSIGNED_EXCLUDED_STATUSES`) to match the ManageEngine view. The debug list is kept but hidden (`SHOW_UNASSIGNED_LIST` in `index.html`).
- **2026-10-15:** **Multi-region approach:** one service token plus region config, not per-user Zoho tokens. A user's own token could see all 124k tickets, so it wouldn't separate regions, and per-user polling multiplies API traffic. See `ROADMAP.md`.
- **2026-10-15:** **Admin key until real sign-in.** `ADMIN_KEY` in `.env`, sent as `X-Admin-Key`, guards `/api/connect` and `/api/check`. With no key set, only loopback requests are admin. Loopback isn't trusted once a key is set, because behind a reverse proxy every request is loopback. Non-admins never see the device code; otherwise anyone could approve the board with their own Zoho account.
- **2026-10-15:** **Token saved to a JSON file** (`data/token.json`, mode 600, written atomically), not SQLite yet. Move it into the database in phase 2 if one exists by then. Renewal failures with a 400 or a Zoho `error` drop the token and delete the file; network errors keep it and retry on the next check.
## Known issues
- The status table scrolls sideways once there are more statuses than fit, because the page is capped at 1,320px. Consider widening the page or narrowing the sidebar.
- All statuses not in `STATUS_NAMES` count as open, including "Need Information" and "On Hold". This is intentional for now; revisit when regions get their own config.

34
docs/ROADMAP.md Normal file
View File

@ -0,0 +1,34 @@
# Roadmap
## Goal
A hosted dashboard where staff in each MCCS region sign in and see their own region's queue. It should be customisable per user, and easy to extend with new panels.
## Target architecture
- **Identity:** users sign in (Zoho web OAuth, or Microsoft if MCCS staff use it) only to identify themselves.
- **Data:** one service connection, signed in once by an admin, with its refresh token kept and auto-renewed. Its SDP account must be able to see every region.
- **Regions:** profiles stored in SQLite (sites, groups, technicians, statuses). Checks run once per region and the results are cached, so API traffic grows with regions, not viewers.
- **Access:** users are assigned to regions and the server enforces it. An admin role manages regions and assignments.
- **Extensibility:** each panel is a module with server code to fetch its data and page code to draw it. The weather card is the first example.
## Phases
Phases 2 to 5 (regions as configuration, user sign-in, admin pages, deployment) were dropped on 2026-10-15. Don't start them. The whole auth process is being rebuilt to run on a server instead of locally, and the plan after phase 1 will be redrawn around that. Until then, the Identity, Data and Access bullets in the target architecture above may be out of date. The open questions below still need answering.
- [ ] **Later:** per-user preferences (default region, hidden columns, sound) and panel modules.
## Open questions
- Are regions a **security boundary** (staff can't see other regions) or a **convenience** (default view with a switcher)?
- Do MCCS staff sign in with Microsoft 365 or Entra ID? That decides between Zoho and Microsoft sign-in.
- Does SDP itself restrict technicians to certain sites? If it does, per-user tokens become an option.
## Done
- Dashboard: stat tiles, technician × status table, live closed feed with the ding.
- Central region scoping by site and group.
- Unassigned counter. Verified to match the ManageEngine "Central Region Unassigned" view on 2026-10-15.
- Weather card (OpenWeatherMap).
- Fixed SDP paging and the date filter (see `NOTES.md`).
- Phase 1: the Zoho refresh token is kept, the access token is renewed 5 minutes before it expires, and the sign-in is saved to `data/token.json` (`TOKEN_FILE`) so it survives restarts.
- Phase 1b: Connect and Refresh are admin-only (`ADMIN_KEY`, or localhost when no key is set), and only admins see the device code. Phase 3 replaces the key with real sign-in.

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,
"requires": true,
"packages": {
"": {
"name": "sdp-request-watcher",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"axios": "^1.13.2",
"dotenv": "^17.4.2",
"express": "^5.2.1"
}
"axios": "1.x",
"dotenv": "18.x",
"express": "5.x"
},
"devDependencies": {}
},
"node_modules/accepts": {
"version": "2.0.0",
@ -228,10 +233,13 @@
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"version": "18.0.8",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz",
"integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==",
"license": "BSD-2-Clause",
"bin": {
"dotenv": "dist/index.cjs"
},
"engines": {
"node": ">=12"
},

View File

@ -6,17 +6,22 @@
"type": "git",
"url": "https://git.vfsh.dev/sgrant/sdp-request-watcher.git"
},
"license": "ISC",
"license": "MIT",
"author": "Skylar Grant",
"contributors": [
"Isaac Godin",
"Claude (Anthropic) <noreply@anthropic.com>"
],
"type": "commonjs",
"main": "src/server.js",
"scripts": {
"app": "node src/server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"axios": "^1.13.2",
"dotenv": "^17.4.2",
"express": "^5.2.1"
"axios": "1.x",
"dotenv": "18.x",
"express": "5.x"
},
"devDependencies": {}
}

View File

@ -34,7 +34,7 @@ function pollForToken(clientId, clientSecret, deviceCode, interval, expiresIn) {
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams);
if (res.data.access_token) {
clearInterval(timer);
resolve(res.data.access_token);
resolve(res.data);
}
} catch (err) {
}
@ -54,7 +54,7 @@ async function startAuthentication({ clientId, clientSecret, state }) {
console.log('Code: ', deviceData.user_code);
try {
const accessToken = await pollForToken(
const tokenData = await pollForToken(
clientId,
clientSecret,
deviceData.device_code,
@ -65,7 +65,17 @@ async function startAuthentication({ clientId, clientSecret, state }) {
state.auth = 'connected';
state.verificationUrl = null;
state.userCode = null;
return { accessToken };
// only issued because the device code asked for access_type=offline
if (!tokenData.refresh_token) {
console.warn('Zoho sent no refresh token; the board will stop when the access token expires');
}
return {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || null,
expiresAt: Date.now() + (tokenData.expires_in || 3600) * 1000
};
} catch (err) {
state.auth = 'error';
state.lastError = err.message;
@ -75,6 +85,31 @@ async function startAuthentication({ clientId, clientSecret, state }) {
}
}
// Swaps the long-lived refresh token for a new access token (they last about an hour).
async function refreshAccessToken({ clientId, clientSecret, refreshToken }) {
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token'
});
const res = await axios.post('https://accounts.zoho.com/oauth/v2/token', params);
// zoho reports a bad refresh token as a 200 with an error field, not an http error
if (!res.data || !res.data.access_token) {
const err = new Error('Zoho refused the saved sign-in (' + (res.data?.error || 'no access token') + ')');
err.revoked = true;
throw err;
}
return {
accessToken: res.data.access_token,
expiresAt: Date.now() + (res.data.expires_in || 3600) * 1000
};
}
module.exports = {
startAuthentication
startAuthentication,
refreshAccessToken
};

View File

@ -1,7 +1,10 @@
const { startAuthentication } = require('./auth');
const { fetchClosedTickets } = require('./tickets');
const { startAuthentication, refreshAccessToken } = require('./auth');
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes }) {
// renew this long before expiry so a slow check never runs on a dead token
const RENEW_EARLY_MS = 5 * 60 * 1000;
function createBoardService({ clientId, clientSecret, tokenStore, technicians, statusSet, closedStatuses, unassignedExcludedStatuses, scope, windowMinutes, checkMinutes, workloadMinutes }) {
const state = {
auth: 'disconnected',
verificationUrl: null,
@ -10,11 +13,84 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
lastError: null
};
const workload = {
updatedAt: null,
error: null,
truncated: false,
statuses: [],
technicians: []
};
const unassigned = {
updatedAt: null,
error: null,
truncated: false,
count: 0,
discarded: 0,
tickets: []
};
const tickets = new Map();
let accessToken = null;
// { accessToken, refreshToken, expiresAt }
let token = null;
let renewing = null;
let pollingTimer = null;
let workloadTimer = null;
function saveToken() {
try {
tokenStore.save(token);
} catch (err) {
console.error('Could not save token, it will be lost on restart:', err.message);
}
}
// the saved sign-in is no good any more; go back to needing an admin to connect
function dropToken(message) {
token = null;
tokenStore.clear();
stopPolling();
state.auth = 'error';
state.lastError = message;
}
async function renewToken() {
try {
const renewed = await refreshAccessToken({ clientId, clientSecret, refreshToken: token.refreshToken });
token = { ...token, ...renewed };
saveToken();
console.log('Access token renewed');
} catch (err) {
// a 400 from zoho means the refresh token itself was rejected; anything else is
// probably the network, so keep the token and try again on the next check
if (err.revoked || err.response?.status === 400) {
dropToken(err.message + '. An admin needs to connect again.');
} else {
throw err;
}
}
}
async function getAccessToken() {
if (!token) return null;
if (Date.now() < token.expiresAt - RENEW_EARLY_MS) return token.accessToken;
if (!token.refreshToken) {
dropToken('The Zoho sign-in expired and there is no refresh token. An admin needs to connect again.');
return null;
}
// the closed and workload checks can both land here at once; renew only once
if (!renewing) {
renewing = renewToken().finally(() => {
renewing = null;
});
}
await renewing;
return token ? token.accessToken : null;
}
async function refreshTickets() {
const accessToken = await getAccessToken();
if (!accessToken) return;
await fetchClosedTickets({
@ -23,10 +99,31 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
tickets,
technicians,
statusSet,
scope,
windowMinutes
});
}
async function refreshWorkload() {
const accessToken = await getAccessToken();
if (!accessToken) return;
await fetchWorkload({
accessToken,
workload,
technicians,
closedStatuses,
scope
});
await fetchUnassigned({
accessToken,
unassigned,
excludedStatuses: unassignedExcludedStatuses,
scope
});
}
function startPolling() {
if (pollingTimer) return;
@ -36,6 +133,33 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
state.lastCheck = Date.now();
});
}, checkMinutes * 60 * 1000);
workloadTimer = setInterval(() => {
refreshWorkload().catch(err => {
workload.error = err.message;
workload.updatedAt = Date.now();
});
}, workloadMinutes * 60 * 1000);
}
function stopPolling() {
clearInterval(pollingTimer);
clearInterval(workloadTimer);
pollingTimer = null;
workloadTimer = null;
}
async function startChecks() {
// timers first, so a failed first check (say the network is down at boot) still retries
startPolling();
await refreshTickets().catch(err => {
state.lastError = err.message;
state.lastCheck = Date.now();
});
await refreshWorkload().catch(err => {
workload.error = err.message;
workload.updatedAt = Date.now();
});
}
async function connect() {
@ -47,11 +171,23 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
if (!authResult?.accessToken) return;
accessToken = authResult.accessToken;
token = authResult;
saveToken();
console.log('Authorization successful');
await refreshTickets();
startPolling();
await startChecks();
}
// picks up the sign-in saved by a previous run, if there is one
async function resume() {
const saved = tokenStore.load();
if (!saved) return;
token = saved;
state.auth = 'connected';
console.log('Resumed saved Zoho sign-in');
await startChecks();
}
function failConnection(err) {
@ -66,18 +202,23 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
...state,
windowMinutes,
checkMinutes,
workloadMinutes,
watching: technicians,
scope,
serverTime: Date.now(),
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt),
workload,
unassigned
};
}
async function checkNow() {
await refreshTickets();
await Promise.all([refreshTickets(), refreshWorkload()]);
}
return {
connect,
resume,
checkNow,
failConnection,
getState

View File

@ -2,3 +2,18 @@ CLIENT_ID=
CLIENT_SECRET=
TECHNICIAN_NAMES=First Last, First Last
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
# optional: key for Connect and Refresh now (enter it with the page's Admin button). Without it, only this machine can use them
ADMIN_KEY=
# optional: where the Zoho sign-in is saved so it survives restarts (default data/token.json)
TOKEN_FILE=

View File

@ -3,19 +3,20 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Closed ticket board</title>
<title>Help desk dashboard</title>
<meta name="author" content="Skylar Grant, Isaac Godin (design), Claude by Anthropic">
<link rel="preconnect" href="https://fonts.googleapis.com">
<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>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');
:root {
--ground: hsl(210, 18%, 8%);
--card: hsl(210, 16%, 13%);
--card-raised: hsl(210, 15%, 16%);
--ink: hsl(30, 30%, 96%);
--muted: hsl(215, 12%, 68%);
--faint: hsl(215, 10%, 42%);
--rule: hsl(210, 12%, 24%);
--signal: hsl(28, 95%, 58%);
--signal-soft: hsl(28, 70%, 22%);
@ -27,6 +28,7 @@
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
@ -42,10 +44,11 @@ body {
}
.wrap {
max-width: 860px;
width: 100%;
max-width: 1320px;
flex: 1;
margin: 0 auto;
padding: 32px 20px 80px;
padding: 28px 24px 48px;
}
/* ---- header ---- */
@ -53,7 +56,7 @@ body {
header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
gap: 12px 20px;
padding-bottom: 14px;
border-bottom: 2px solid var(--ink);
@ -71,7 +74,6 @@ h1 {
display: flex;
align-items: center;
gap: 7px;
margin-left: auto;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
@ -96,6 +98,13 @@ h1 {
50% { opacity: 0.25; }
}
.actions {
display: flex;
align-items: center;
gap: 16px;
margin-left: auto;
}
.meta {
display: flex;
flex-wrap: wrap;
@ -124,7 +133,7 @@ h1 {
.panel p {
margin: 0 0 16px;
color: var(--muted);
max-width: 52ch;
max-width: 80ch;
}
.code {
@ -165,6 +174,40 @@ button.quiet {
text-underline-offset: 3px;
}
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 {
margin-top: 20px;
@ -175,55 +218,188 @@ button.quiet:hover { background: none; color: var(--ink); }
}
.error strong { display: block; font-family: var(--mono); font-size: 12px; color: var(--alert); }
/* ---- tallies ---- */
/* ---- stat tiles ---- */
.tallies {
display: flex;
flex-wrap: wrap;
gap: 8px;
.stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
margin-top: 24px;
}
.tally {
display: flex;
align-items: baseline;
gap: 8px;
padding: 5px 12px;
.stat {
padding: 16px 18px;
background: var(--card);
border: 1px solid var(--rule);
font-size: 13px;
}
.tally b {
.stat .label {
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;
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 { margin-top: 20px; }
.feed {
max-height: 640px;
overflow-y: auto;
}
.ticket {
--age: 0;
display: grid;
grid-template-columns: 74px 1fr;
background: var(--card);
border: 1px solid var(--rule);
border-bottom: none;
grid-template-columns: 66px 1fr;
border-bottom: 1px solid var(--rule);
}
.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
of color as it ages out of the window. */
/* how long ago this closed, drained of color as it ages out of the hour */
.rail {
display: flex;
flex-direction: column;
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%));
font-family: var(--mono);
font-size: 15px;
font-size: 14px;
font-weight: 500;
color: color-mix(in oklab, var(--signal), var(--muted) calc(var(--age) * 100%));
}
@ -235,11 +411,11 @@ button.quiet:hover { background: none; color: var(--ink); }
color: var(--muted);
}
.body { padding: 14px 16px 14px 4px; min-width: 0; }
.body { padding: 12px 14px 12px 4px; min-width: 0; }
.subject {
margin: 0;
font-size: 15px;
font-size: 14px;
font-weight: 500;
overflow-wrap: anywhere;
}
@ -251,8 +427,8 @@ button.quiet:hover { background: none; color: var(--ink); }
}
.byline {
margin: 3px 0 0;
font-size: 13px;
margin: 2px 0 0;
font-size: 12px;
color: var(--muted);
}
.byline b { color: var(--ink); font-weight: 500; }
@ -264,32 +440,103 @@ button.quiet:hover { background: none; color: var(--ink); }
}
.empty {
margin-top: 20px;
padding: 40px 24px;
background: var(--card);
border: 1px dashed var(--rule);
text-align: center;
color: var(--muted);
}
.empty p { margin: 0; }
footer {
margin-top: 28px;
font-family: var(--font-ui);
font-size: 11px;
color: var(--ink);
}
body > footer {
margin-top: auto;
max-width: 860px;
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; }
body > footer p + p { margin-top: 4px; }
/* ---- 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) {
.ticket { grid-template-columns: 62px 1fr; }
.rail { font-size: 13px; padding-left: 10px; }
@media (max-width: 720px) {
.wrap { padding: 20px 16px 40px; }
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) {
@ -301,17 +548,22 @@ body > footer {
<div class="wrap">
<header>
<h1>Closed ticket board</h1>
<h1>Help desk dashboard</h1>
<div class="status" id="status" data-auth="disconnected">
<span class="dot"></span><span id="statusText">Not connected</span>
</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>
<button class="quiet" id="adminBtn" onclick="toggleAdmin()" hidden>Admin</button>
</div>
</header>
<div class="meta" id="meta"></div>
<div id="main"></div>
<footer id="footer"></footer>
</div>
<script>
@ -327,11 +579,54 @@ 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));
// connect and refresh are admin-only; the key is remembered per browser and sent with every call
let adminKey = '';
try { adminKey = localStorage.getItem('sdp-admin-key') || ''; } catch (err) {}
function api(url, options = {}) {
return fetch(url, { ...options, headers: adminKey ? { 'X-Admin-Key': adminKey } : {} });
}
function setAdminKey(key) {
adminKey = key;
try {
if (key) localStorage.setItem('sdp-admin-key', key);
else localStorage.removeItem('sdp-admin-key');
} catch (err) {}
}
async function toggleAdmin() {
if (adminKey) {
setAdminKey('');
return load();
}
const key = prompt('Admin key');
if (!key) return;
setAdminKey(key.trim());
await load();
if (data && !data.isAdmin) {
setAdminKey('');
alert('That admin key was not accepted.');
load();
}
}
let data = null;
let seen = new Set();
let firstLoad = true;
let flashTechs = new Set();
const STATUS_TEXT = {
disconnected: 'Not connected',
@ -358,7 +653,7 @@ function esc(text) {
async function load() {
try {
const res = await fetch('/api/state');
const res = await api('/api/state');
data = await res.json();
render();
} catch (err) {
@ -369,22 +664,36 @@ async function load() {
function render() {
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;
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
$('checkBtn').hidden = data.auth !== 'connected' || !data.isAdmin;
// without a key configured, admin just means "on the server's machine"; nothing to enter
$('adminBtn').hidden = !data.adminKeyRequired;
$('adminBtn').textContent = adminKey ? 'Lock' : 'Admin';
const wl = data.workload || {};
$('meta').innerHTML = [
'Checks every ' + data.checkMinutes + ' min',
'Window ' + data.windowMinutes + ' min',
'Closed tickets checked every ' + data.checkMinutes + ' min',
'Status counts every ' + data.workloadMinutes + ' min',
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) : ''
].filter(Boolean).map(t => '<span>' + esc(t) + '</span>').join('');
let html = '';
if (data.auth === 'disconnected' || data.auth === 'error') {
if ((data.auth === 'disconnected' || data.auth === 'error') && !data.isAdmin) {
html += `<div class="panel">
<h2>Not connected to the help desk</h2>
<p>An admin needs to connect this board to Zoho. It starts updating on its own once they do.</p>
</div>`;
} else if (data.auth === 'disconnected' || data.auth === 'error') {
html += `<div class="panel">
<h2>Connect to the help desk</h2>
<p>Zoho needs you to approve this board once. Connecting opens an authorization
@ -393,45 +702,219 @@ function render() {
</div>`;
}
if (data.auth === 'pending') {
if (data.auth === 'pending' && !data.isAdmin) {
html += `<div class="panel">
<h2>Connecting</h2>
<p>An admin is approving this board in Zoho.</p>
</div>`;
} else 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">
<h2>Approve the board</h2>
<p>Open the authorization page and confirm this code matches the one shown there.</p>
<div class="code">${esc(data.userCode || '...')}</div>
<a class="button" href="${esc(data.verificationUrl || '#')}">Open authorization page</a>
<p>Requesting a device code from Zoho…</p>
</div>`;
} 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>`;
}
if (data.lastError) {
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') {
const tallies = {};
for (const t of data.tickets) tallies[t.technician] = (tallies[t.technician] || 0) + 1;
const names = Object.keys(tallies).sort((a, b) => tallies[b] - tallies[a]);
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>';
html += renderStats(wl);
html += `<div class="board">
${renderWorkload(wl)}
<div class="side">
${renderWeather()}
${renderFeed()}
</div>
</div>`;
html += renderUnassignedList(data.unassigned || {});
}
// keep the verification list open across the 5s re-renders
const verifyOpen = !!document.querySelector('.verify[open]');
$('main').innerHTML = html;
if (verifyOpen) document.querySelector('.verify').open = true;
$('footer').textContent = data.tickets && data.tickets.length
? data.tickets.length + ' tickets on the board'
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 + '%' : '&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) {
@ -440,7 +923,6 @@ function renderTicket(ticket) {
// 0 = just closed, 1 = fully aged out (one hour on)
const fade = Math.min(age / (60 * 60 * 1000), 1).toFixed(3);
const isNew = !firstLoad && !seen.has(ticket.id);
seen.add(ticket.id);
return `<article class="ticket${isNew ? ' new' : ''}" style="--age:${fade}">
<div class="rail">
@ -457,24 +939,46 @@ function renderTicket(ticket) {
async function connect(btn) {
btn.disabled = true;
btn.textContent = 'Connecting...';
await fetch('/api/connect', { method: 'POST' });
load();
await api('/api/connect', { method: 'POST' });
// 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) {
btn.disabled = true;
btn.textContent = 'Checking...';
await fetch('/api/check', { method: 'POST' });
btn.textContent = 'Refreshing...';
try {
await api('/api/check', { method: 'POST' });
} finally {
btn.disabled = false;
btn.textContent = 'Refresh now';
}
load();
}
let weather = null;
async function loadWeather() {
try {
const res = await fetch('/api/weather');
weather = await res.json();
render();
} catch (err) {}
}
load();
loadWeather();
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
</script>
<footer>
<p>Design and Credits: <span style="color: hsl(28, 95%, 58%);">Isaac Godin</span></p>
<p>Dashboard by <span style="color: hsl(28, 95%, 58%);">Skylar Grant</span> with <span style="color: hsl(28, 95%, 58%);">Claude</span> (Anthropic)</p>
</footer>
</body>
</html>

View File

@ -1,13 +1,47 @@
const crypto = require('crypto');
const express = require('express');
function createRoutes(boardService) {
const router = express.Router();
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
router.get('/state', (req, res) => {
res.json(boardService.getState());
// Stand-in for real user sign-in (roadmap phase 3). With ADMIN_KEY set, the request must
// carry it in X-Admin-Key. Without it, only requests from this machine count as admin.
// Loopback isn't trusted once a key is set, since behind a reverse proxy everything is loopback.
function createAdminCheck(adminKey) {
const hash = value => crypto.createHash('sha256').update(String(value)).digest();
const keyHash = adminKey ? hash(adminKey) : null;
return function isAdmin(req) {
if (!keyHash) return LOOPBACK.has(req.socket.remoteAddress);
const given = req.get('X-Admin-Key');
return !!given && crypto.timingSafeEqual(hash(given), keyHash);
};
}
function createRoutes(boardService, weatherService, { adminKey } = {}) {
const router = express.Router();
const isAdmin = createAdminCheck(adminKey);
function requireAdmin(req, res, next) {
if (isAdmin(req)) return next();
res.status(403).json({ ok: false, error: 'Admin only' });
}
router.get('/weather', async (req, res) => {
res.json(await weatherService.getWeather());
});
router.post('/connect', async (req, res) => {
router.get('/state', (req, res) => {
const admin = isAdmin(req);
const state = { ...boardService.getState(), isAdmin: admin, adminKeyRequired: !!adminKey };
// whoever holds the device code decides whose Zoho account the board runs as
if (!admin) {
state.verificationUrl = null;
state.userCode = null;
}
res.json(state);
});
router.post('/connect', requireAdmin, async (req, res) => {
const currentState = boardService.getState();
if (currentState.auth === 'pending' || currentState.auth === 'connected') {
return res.json({ ok: true });
@ -24,7 +58,7 @@ function createRoutes(boardService) {
}
});
router.post('/check', async (req, res) => {
router.post('/check', requireAdmin, async (req, res) => {
await boardService.checkNow();
res.json({ ok: true });
});

View File

@ -1,3 +1,5 @@
// sdp-request-watcher: help desk dashboard for ManageEngine SDP Cloud
// Skylar Grant, with Claude (Anthropic). Page design by Isaac Godin.
require('dotenv').config({ quiet: true });
@ -5,8 +7,10 @@ const path = require('path');
const express = require('express');
const { createBoardService } = require('./boardService');
const { createRoutes } = require('./routes');
const { createWeatherService } = require('./weather');
const { createTokenStore } = require('./tokenStore');
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, ADMIN_KEY } = process.env;
const PORT = process.env.PORT || 3000; // default 3000
if (!CLIENT_ID || !CLIENT_SECRET) {
@ -14,12 +18,26 @@ if (!CLIENT_ID || !CLIENT_SECRET) {
process.exit(1);
}
const technicians = (TECHNICIAN_NAMES ? TECHNICIAN_NAMES.split(',') : []).map(t => t.trim()).filter(Boolean);
const statuses = (STATUS_NAMES ? STATUS_NAMES.split(',') : []).map(s => s.trim()).filter(Boolean);
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
const technicians = toList(TECHNICIAN_NAMES);
const statuses = toList(STATUS_NAMES);
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 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
// the saved Zoho sign-in; holds a refresh token, so keep it out of git and off shared disks
const TOKEN_FILE = process.env.TOKEN_FILE || path.join(__dirname, '..', 'data', 'token.json');
const app = express();
@ -27,15 +45,29 @@ const app = express();
const boardService = createBoardService({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
tokenStore: createTokenStore(TOKEN_FILE),
technicians,
statusSet,
closedStatuses,
unassignedExcludedStatuses,
scope,
windowMinutes: WINDOW_MINUTES,
checkMinutes: CHECK_MINUTES
checkMinutes: CHECK_MINUTES,
workloadMinutes: WORKLOAD_MINUTES
});
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, { adminKey: ADMIN_KEY }));
if (!ADMIN_KEY) console.log('ADMIN_KEY not set: Connect and Refresh only work from this machine');
boardService.resume().catch(err => {
boardService.failConnection(err);
});
//starts listening on port specified
app.listen(PORT, () => {

View File

@ -1,5 +1,9 @@
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) {
if (!completedTime) return null;
const raw = typeof completedTime === 'object' ? completedTime.value : completedTime;
@ -14,14 +18,92 @@ function getRelevantTicketTime(ticket) {
|| 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;
try {
const windowStart = Date.now() - windowMinutes * 60 * 1000;
const listInfo = {
list_info: {
const data = await getRequests(accessToken, {
row_count: 200,
sort_field: 'last_updated_time',
sort_order: 'desc',
@ -32,29 +114,25 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
'completed_time',
'resolved_time',
'last_updated_time',
'technician'
'technician',
'site',
'group'
],
search_criteria: [
search_criteria: allOf(
{
field: 'last_updated_time',
condition: 'greater_than',
condition: 'greater than',
value: windowStart.toString()
}
]
}
};
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'
}
},
scopeCriteria(scope)
)
});
const found = response.data?.requests || [];
const found = data.requests || [];
for (const ticket of found) {
if (!inScope(ticket, scope)) continue;
const statusName = ticket.status?.name || '';
const statusInternalName = ticket.status?.internal_name || '';
const statusMatches = !statusSet.size
@ -90,15 +168,149 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
state.lastCheck = Date.now();
state.lastError = null;
} catch (err) {
const detail = err.response?.data;
state.lastError = detail?.response_status?.messages?.[0]?.message
|| (typeof detail === 'string' ? detail : null)
|| err.message;
state.lastError = describeError(err);
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 = {
fetchClosedTickets
fetchClosedTickets,
fetchWorkload,
fetchUnassigned
};

37
src/tokenStore.js Normal file
View File

@ -0,0 +1,37 @@
const fs = require('fs');
const path = require('path');
// keeps the Zoho tokens on disk so a restart doesn't need a new sign-in
function createTokenStore(filePath) {
function load() {
try {
const saved = JSON.parse(fs.readFileSync(filePath, 'utf8'));
return saved && saved.refreshToken ? saved : null;
} catch (err) {
if (err.code !== 'ENOENT') console.error('Could not read saved token:', err.message);
return null;
}
}
function save(token) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
// write then rename, so a crash mid-write can't leave a half-written file behind
const tmp = filePath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify({ ...token, savedAt: Date.now() }, null, 2), { mode: 0o600 });
fs.renameSync(tmp, filePath);
}
function clear() {
try {
fs.unlinkSync(filePath);
} catch (err) {
if (err.code !== 'ENOENT') console.error('Could not remove saved token:', err.message);
}
}
return { load, save, clear, filePath };
}
module.exports = {
createTokenStore
};

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
};