A locally run JS app to monitor for request closures using Zoho's SDP Cloud API.
Go to file
Skylar Grant dc684fd7fa
All checks were successful
Build and push image / build (push) Successful in 15s
Sort the technician table by clicking its headers
Replaces the "Sort rows by" setting from the previous commit. Every
header is now a button: a new column starts A to Z for names and highest
first for counts, and a second click reverses it. Rows default to name
A to Z, and each browser remembers its own sort in localStorage, like the
sound toggle. The arrow points toward the larger end (down for A to Z
or lowest first, up for highest first). The tableSort setting is removed
from the server again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:42:31 -04:00
.gitea/workflows Add Docker image, example compose file and Gitea build workflow 2026-10-15 19:38:08 -04:00
.vscode Auth functions, need to add request functionality 2026-01-06 13:36:28 -05:00
docs Sort the technician table by clicking its headers 2026-10-19 11:42:31 -04:00
src Sort the technician table by clicking its headers 2026-10-19 11:42:31 -04:00
.dockerignore Add data volume and document per-user settings 2026-10-15 21:04:22 -04:00
.gitignore Add data volume and document per-user settings 2026-10-15 21:04:22 -04:00
CLAUDE.md Add security document and move session notes into docs 2026-10-15 21:45:46 -04:00
docker-compose.yml Add data volume and document per-user settings 2026-10-15 21:04:22 -04:00
Dockerfile Add data volume and document per-user settings 2026-10-15 21:04:22 -04:00
LICENSE Update credits and licensing 2026-10-15 15:38:59 -04:00
package-lock.json Slopify This Thang 2026-10-15 15:23:05 -04:00
package.json Replace shared device-code token with per-user Zoho sign-in 2026-10-15 19:38:08 -04:00
README.md Sort the technician table by clicking its headers 2026-10-19 11:42:31 -04:00

sdp-request-watcher

A help desk 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.

Everyone signs in with their own Zoho account, and every query runs as that person. It's built as a Docker image (v0idf1sh/sdp-dashboard) to run behind Nginx Proxy Manager, which limits access to MCCS network addresses.

Setup

1. Zoho API client

Create a Server-based Application at https://api-console.zoho.com/add:

  • Homepage URL: the dashboard's address, e.g. https://dashboard.example.com.
  • Authorized Redirect URI: that address plus /auth/callback, e.g. https://dashboard.example.com/auth/callback. It must match PUBLIC_URL exactly. To also run it locally, add http://localhost:3000/auth/callback, or use a second client for local work.

That gives you the client ID and secret. The app asks each user for two scopes: SDPOnDemand.requests.READ, so it can read requests but never change them, and AaaServer.profile.READ, only to show who is signed in.

The old Non-Browser Application client (device-code sign-in) is no longer used.

2. .env file

Copy src/env.example to a file named .env and fill it in. The region, timing and weather settings are the defaults everyone starts with; each person can change their own in Settings (see Settings). 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).

Setting Required What it does
CLIENT_ID, CLIENT_SECRET Yes The Zoho Server-based Application credentials.
SESSION_SECRET Yes At least 32 characters. Encrypts the sign-in cookie. Generate one with openssl rand -hex 32. Changing it signs everyone out.
ALLOWED_EMAIL_DOMAINS Yes Comma-separated email domains allowed to sign in, e.g. mainecc.edu. Any Zoho user can approve the client, so this is what keeps outside Zoho accounts out. Exact match: mainecc.edu doesn't let in sub.mainecc.edu. Checked at sign-in and on every call, so removing a domain signs those people out. The server won't start without it.
PUBLIC_URL Yes when hosted The address people open, with no trailing slash, e.g. https://dashboard.example.com. Zoho sends people back to PUBLIC_URL/auth/callback. Defaults to http://localhost:3000. Cookies are marked secure when it starts with https://.
SESSION_DAYS No How many days a sign-in lasts before Zoho is asked again. Defaults to 7.
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 Closed today count and the status table. Everything else counts as open. Also the default for which statuses put a ticket in the closed feed and ding, which each person can change in Settings. 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 the default zip code for the weather card. Without a key the card is hidden. People pick other locations from the list in src/places.js. A free key from https://openweathermap.org/api is enough. New keys can take a couple of hours to start working.
DATA_DIR No Where saved settings (settings.json) and name suggestions (names.json) are kept. Defaults to data/ in the project root; the Docker image uses /app/data.
PORT No Port the server listens on. Defaults to 3000. Leave it alone in Docker.
TRUST_PROXY No Which proxies to believe for the client's IP address in the logs. Defaults to private and loopback addresses, which covers NPM on the same Docker host.

Spaces around commas are fine in every list.

The central region settings currently in use:

SITE_NAMES=Base Site,ASSIGN,CMCC,CMCC-Damariscotta,EMCC,KVCC,KVCC-Alfond,MCCS
GROUP_NAMES=Customer Support
UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved

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

3. Run it locally

npm install
npm run app

npm run app runs node src/server.js from the project root. Then open http://localhost:3000. Leave PUBLIC_URL unset (or set it to http://localhost:3000) and register http://localhost:3000/auth/callback with the Zoho client.

Testing on the server without Docker

To try a branch on the VPS before building an image, run it directly with Node behind a separate test subdomain:

  • Use Node 24 to match the image, run npm ci, and keep a test .env in the repo root. Give it its own SESSION_SECRET, and set PUBLIC_URL to the test subdomain. Register that subdomain's /auth/callback with the Zoho client.
  • Port 3000 is taken by Gitea on the VPS, so set PORT (the test run uses PORT=3005) in the test .env only. Leave PORT out of the production .env: the container must listen on 3000, which its health check and the NPM host expect.
  • NPM runs in Docker, so localhost in a proxy host means NPM's own container. Forward the test host to the gateway of NPM's network instead. Find it with docker network inspect npm_default --format '{{(index .IPAM.Config 0).Gateway}}'. If ufw is on, allow that network's subnet to the test port (sudo ufw allow from <subnet> to any port 3005 proto tcp).
  • Attach the same access list as production.

Deployment

Image

Pushing a tag that matches v*-prod* (for example v1.1.0-prod1) runs .gitea/workflows/docker.yml on the self-hosted Gitea runner. It builds the image and pushes it to Docker Hub as v0idf1sh/sdp-dashboard:<tag> and :latest. The runner needs Docker, and the repository needs DHUB_UNAME and DHUB_PASS secrets (a Docker Hub access token works for the password). Pulling the new image and restarting the container is done by hand.

git tag v1.1.0-prod1
git push origin v1.1.0-prod1

Server

On the VPS, put docker-compose.yml and a filled-in .env in the same folder, then:

docker compose pull
docker compose up -d

Sign-ins live in each browser's cookie, so restarting or upgrading the container doesn't sign anyone out. The only state is people's saved settings and the name suggestions, in the sdp-data volume mounted at /app/data. Keep that volume when upgrading; losing it only resets everyone to the .env defaults.

The compose file publishes no ports. Docker's port publishing bypasses the host firewall, so 3000:3000 would let anyone reach the app directly and skip NPM's access list. Instead the container joins NPM's Docker network (npm_default by default; set NPM_NETWORK if yours is named differently, and check with docker network ls).

Nginx Proxy Manager

  • Proxy host: the dashboard's subdomain, forwarding to http://sdp-dashboard:3000.
  • SSL: request a certificate and turn on Force SSL. Sign-in cookies are secure-only when PUBLIC_URL is https://.
  • Access list: allow MCCS' public IP blocks and deny everything else. Attach it to the proxy host. To check it, load the site from a phone on mobile data; it should return 403 Forbidden.
  • Cloudflare: if the DNS record is proxied (orange cloud), NPM may see Cloudflare's addresses instead of the visitor's. Then the access list blocks everyone, MCCS included. If allowed addresses get 403s, either switch the record to DNS only, or add real_ip_header CF-Connecting-IP; in the proxy host's Advanced tab. With the proxy on, use Cloudflare SSL mode Full (strict); Flexible combined with Force SSL causes a redirect loop.

Signing in

Opening the dashboard shows a Sign in with Zoho button. It goes to Zoho's consent page, and Zoho sends you back signed in. Use the Zoho account you use for ServiceDesk Plus. The dashboard shows what that account can see, limited to the region in that person's settings.

The sign-in is kept in an encrypted cookie in that browser for SESSION_DAYS (7 by default), and the hourly Zoho access token is renewed automatically. Sign out clears the cookie and revokes the token at Zoho. If Zoho or SDP stops accepting the sign-in, the page goes back to the sign-in button.

Security

The full security model, its limits and the open items are in docs/SECURITY.md.

  • Network: NPM only lets in MCCS' public IP addresses (campuses and VPN). From anywhere else the subdomain doesn't answer.
  • Allowed domains: any Zoho account can complete the Zoho consent screen, so the server only creates a session for emails at ALLOWED_EMAIL_DOMAINS, and checks again on every call. A refused sign-in has its Zoho token revoked straight away.
  • Per-user sign-in: every request needs the user's own Zoho sign-in. There is no shared token, so a reachable page with no sign-in shows nothing.
  • Tokens: the Zoho tokens live only in the user's cookie, encrypted and authenticated with SESSION_SECRET (AES-256-GCM). The cookie is HttpOnly, so page scripts can't read it, and SameSite=Lax. The server doesn't store tokens or sessions.
  • Read-only: the only SDP scope is SDPOnDemand.requests.READ.
  • Settings: saved on the server, keyed by the email Zoho returns at sign-in. They hold names and numbers only, never tokens. The server checks every value (plain names, list lengths, refresh intervals, weather locations from a fixed list). Changing the region can't show anyone more than SDP already lets them see, because every query uses their own token.

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. Click a column header to sort by it, and click it again to reverse. Rows start A to Z by name; the arrow points toward the larger end (↓ is A to Z or lowest first, ↑ is highest first). Each browser remembers its own sort. 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.

Settings

⚙ Settings in the header, and a setup prompt on someone's first visit, let each person choose:

  • Sites, groups and technicians to watch. Empty means everyone. Suggestions come from names seen on recent tickets anywhere in SDP (refreshed at most hourly and shared by everyone). Any name can be typed, but it must match SDP's spelling; a name that differs only in case is saved with SDP's spelling.
  • Timing: how often the status table recounts (2 to 60 minutes) and how long closed tickets stay in the feed (1 to 24 hours). The closed check itself always runs every minute.
  • Weather location, from the list of campuses in src/places.js.
  • Which statuses count as closed for the feed and ding. Starts as STATUS_NAMES. SDP closes a resolved ticket on its own after a few days, so ticking Resolved gives the alert when the ticket is resolved instead. With Resolved ticked, a ticket's time in the feed is its resolve time, so the automatic close later doesn't ding again.
  • What's shown: the stat tiles, weather card and closed feed can be hidden (the ding still plays with the feed hidden), and so can individual status columns.

Settings are saved to the person's Zoho account on the server, so they follow them to any browser. Only the values that differ from .env are stored, so someone who never changed the region picks up later changes to .env. Reset to defaults goes back to .env. The closed and open status rules for the status table, Closed today and the unassigned count aren't per person; they stay in .env.

The browser runs the checks, using the signed-in user's token, so every open dashboard makes its own SDP calls. That's one small query a minute and a recount at the person's chosen interval (WORKLOAD_MINUTES by default). On first load it fetches the feed's full length (12 hours by default) of closes.

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 log (docker compose logs) then has 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, status, site and group (no subjects; see docs/SECURITY.md). 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.
src/auth.js Zoho web sign-in: the authorization URL, code exchange, token renewal and revoke.
src/session.js The encrypted session cookie, and renewing the access token when it's due.
src/routes.js /auth/login, /auth/callback and /auth/logout, plus /api/session, /api/settings, /api/names, /api/closed, /api/workload and /api/weather, which need a sign-in.
src/settings.js Each user's saved settings: validation, limits, and merging over the .env defaults.
src/names.js The shared cache of site, group and technician names for the settings suggestions.
src/store.js The small JSON file store both of those use.
src/places.js The weather locations people can pick from.
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 per location.
src/public/index.html The dashboard page. It runs the checks and keeps the closed feed.
Dockerfile, docker-compose.yml The image, and an example of how to run it.
.gitea/workflows/docker.yml Builds and pushes the image on v*-prod* tags.

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

Node.js 18 or newer to run it directly; the image uses Node 24. Dependencies are axios, dotenv and express, all installed by npm install.

Credits

  • Skylar Grant: project owner and author.
  • Isaac Godin: original page design.
  • Claude (Anthropic, via Claude Code): dashboard, region scoping, unassigned counter, weather card, SDP query fixes, per-user sign-in and Docker packaging, built with Skylar.

Reference materials (provided by Skylar)

License

MIT. See LICENSE.