Compare commits

..

No commits in common. "v2.0.0-prod" and "main" have entirely different histories.

25 changed files with 406 additions and 1936 deletions

View File

@ -1,12 +0,0 @@
.git
.gitea
.vscode
node_modules
npm-debug.log*
.env
.env.*
docs
images
*.md
Dockerfile
docker-compose*.yml

View File

@ -1,43 +0,0 @@
# Builds the image and pushes it to Docker Hub when a production tag (e.g. v1.1.0-prod1) is pushed.
# Restarting the container on the VPS is done by hand for now.
name: Build and push image
on:
push:
tags:
- 'v*-prod*'
env:
IMAGE: v0idf1sh/sdp-dashboard
jobs:
build:
runs-on: self-hosted
steps:
- name: Check out
uses: actions/checkout@v4
- name: Log in to Docker Hub
env:
DHUB_UNAME: ${{ secrets.DHUB_UNAME }}
DHUB_PASS: ${{ secrets.DHUB_PASS }}
run: echo "$DHUB_PASS" | docker login --username "$DHUB_UNAME" --password-stdin
- name: Build
run: |
docker build \
--pull \
--label "org.opencontainers.image.version=${{ gitea.ref_name }}" \
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
-t "$IMAGE:${{ gitea.ref_name }}" \
-t "$IMAGE:latest" \
.
- name: Push
run: |
docker push "$IMAGE:${{ gitea.ref_name }}"
docker push "$IMAGE:latest"
- name: Log out
if: always()
run: docker logout

View File

@ -1,44 +0,0 @@
# 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's a stateless Node/Express server, shipped as the Docker image `v0idf1sh/sdp-dashboard`, running behind Nginx Proxy Manager with an MCCS IP allow list. Each user signs in with their own Zoho account; the session is an encrypted cookie. The central region (KVCC, EMCC, CMCC, MCCS) is configured through `.env`.
- **Goal and next steps:** per-browser settings and panel modules. 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>. `.env` needs `CLIENT_ID`/`CLIENT_SECRET` from a Zoho **Server-based Application** client with `http://localhost:3000/auth/callback` as a redirect URI, plus a `SESSION_SECRET` of 32 or more characters.
Releases: push a `v*-prod*` tag. `.gitea/workflows/docker.yml` builds and pushes the image; the VPS is updated by hand.
## Layout
- `src/server.js`: reads `.env`, holds the region `settings` and `timing`, and starts Express.
- `src/auth.js`: Zoho web OAuth (authorization URL, code exchange, renewal, revoke, user info).
- `src/session.js`: the encrypted session cookie and the `requireSession` middleware, which renews the access token when it's due.
- `src/routes.js`: `/auth/*` (login, callback, logout) and `/api/*` (session, closed, workload, weather; all need a sign-in).
- `src/tickets.js`: all SDP queries (closed feed, status table, closed today, unassigned). A 401 from SDP is rethrown so the route signs the user out.
- `src/weather.js`: OpenWeatherMap, cached for 10 minutes and shared by all users.
- `src/public/index.html`: the whole front end in one file (inline CSS and JS, no build step). It runs the polling timers and keeps the closed feed.
- `Dockerfile`, `docker-compose.yml` (example for the VPS), `.gitea/workflows/docker.yml`.
## Working agreements
- Never print or commit `.env` values (Zoho client secret, `SESSION_SECRET`, OpenWeatherMap key). Redact them when inspecting.
- Never send Zoho tokens to the page or put them in logs. They stay inside the encrypted cookie and server memory for the length of a request.
- There are no automated tests yet. Verify server logic by stubbing `axios.get` and `axios.post`. 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`, `fetch`, `location` and `history`, or by taking a screenshot with headless Edge.
- The sandbox can't bind TCP ports, but it can listen on a unix socket with a short relative path (`cd` to the scratchpad and listen on `x.sock`). To run `server.js` end to end, patch `express.application.listen`. Docker isn't available in the sandbox.
- 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,19 +0,0 @@
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
# install dependencies first so code-only changes reuse this layer
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY src ./src
# no volume: sessions live in each user's browser, so the container keeps no state
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
CMD ["node", "src/server.js"]

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Skylar Grant
Copyright (c) 2026 sgrant
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:

165
README.md
View File

@ -1,165 +1,48 @@
# 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.
## 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
## Setup
Technicians and Statuses are checked in the .env file
### 1. Zoho API client
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
Create a **Server-based Application** at <https://api-console.zoho.com/add>:
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.
- **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.
## Site Setup
Open the site after the server is running, then connect the Zoho account from the board.
### 2. `.env` file
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.
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).
What you should see:
| 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. |
| `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 ding and the Closed today count. Everything else counts as open. Defaults to `Closed,Resolved,Canceled`. |
| `SITE_NAMES` | No | Only include requests from these sites (the region). Leave it empty for all sites. |
| `GROUP_NAMES` | No | Only include requests in these support groups, e.g. `Customer Support`. Leave it empty for all groups. |
| `UNASSIGNED_EXCLUDED_STATUSES` | No | Statuses the Unassigned requests counter leaves out. Defaults to `STATUS_NAMES`. |
| `WORKLOAD_MINUTES` | No | How often the status table and unassigned count refresh, in minutes. Defaults to 5. |
| `OWM_API_KEY`, `OWM_ZIP_CODE` | No | OpenWeatherMap key and zip code for the weather card. Leave either one empty to hide the card. A free key from <https://openweathermap.org/api> is enough. New keys can take a couple of hours to start working. |
| `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. |
![Site setup step 1](images/1.png)
Spaces around commas are fine in every list.
![Site setup step 2](images/2.png)
The central region settings currently in use:
![Site setup step 3](images/3.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 4](images/4.png)
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
```text
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.
```text
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:
```text
docker compose pull
docker compose up -d
```
The container keeps no state, so it needs no volume, and restarting or upgrading it doesn't sign anyone out.
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 `.env`.
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
- **Network:** NPM only lets in MCCS' public IP addresses (campuses and VPN). From anywhere else the subdomain doesn't answer.
- **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`.
## 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.
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 every `WORKLOAD_MINUTES`. On first load it fetches the last 12 hours of closes to fill the feed.
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 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. |
| `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/closed`, `/api/workload` and `/api/weather`, which need a sign-in. |
| `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/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.
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.
## Requirements
axios
dotenv
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)
## 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)
- [Non-Browser Apps - Polling Request](https://www.zoho.com/accounts/protocol/oauth/devices/polling-request.html)
- [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).

View File

@ -1,71 +0,0 @@
# Session summary: 2026-10-15
A Claude Code session that turned the closed-ticket board into a dashboard, then rebuilt sign-in so it can be hosted. Background is in `CLAUDE.md`, `docs/ROADMAP.md` and `docs/NOTES.md`.
## Where things stand
The dashboard is now a stateless server meant to run as the Docker image `v0idf1sh/sdp-dashboard` on the RackNerd VPS, behind Nginx Proxy Manager. NPM's access list limits the site to MCCS' public IP blocks. Every user signs in with their own Zoho account. Filtering is still the central region from `.env`.
It's committed and pushed on branch `v2.0.0`. **Tested live on 2026-10-15** as a bare-metal run on the VPS (Node, `PORT=3005`) behind `dash-test` in NPM: Zoho sign-in, live SDP data, Sign out, and the IP access list (mobile data gets 403 Forbidden) all work. Still to check: hourly token renewal, and the first Docker image build and deploy.
## Auth rebuild and hosting (this session's last piece of work)
- **Sign-in:** Zoho web OAuth (`/auth/login` → Zoho consent → `/auth/callback`). It needs a new **Server-based Application** client in the Zoho API console, with redirect URI `PUBLIC_URL/auth/callback`; the old Non-Browser client is no longer used. Scopes: `SDPOnDemand.requests.READ` and `AaaServer.profile.READ` (for the name in the header).
- **Sessions:** the Zoho tokens live in an AES-256-GCM encrypted, `HttpOnly`, `SameSite=Lax` cookie (`Secure` over https), keyed by `SESSION_SECRET`. Each sign-in lasts `SESSION_DAYS` (default 7). The access token renews automatically; concurrent calls share one renewal. Sign out revokes the refresh token at Zoho. A rejected token, from Zoho or a 401 from SDP, sends the page back to the sign-in button.
- **Stateless server:** removed `boardService.js`, `tokenStore.js`, the device-code flow, `ADMIN_KEY` and `data/token.json` (phase 1 was superseded). The page runs the checks itself (`/api/closed` every minute, with 12 hours on first load; `/api/workload` every `WORKLOAD_MINUTES`) and keeps the closed feed. All `/api` routes, weather included, need a sign-in. `/healthz` is public for the Docker healthcheck.
- **Docker:** `Dockerfile` (node:24-alpine, runs as the `node` user, with a healthcheck) and `.dockerignore`. The example `docker-compose.yml` publishes **no ports**, because Docker's port publishing bypasses ufw and would skip NPM's access list. It joins NPM's network instead (`NPM_NETWORK`, default `npm_default`).
- **CI:** `.gitea/workflows/docker.yml` runs on `v*-prod*` tag pushes on the `self-hosted` runner. It logs in with the `DHUB_UNAME` and `DHUB_PASS` secrets, then builds and pushes `:<tag>` and `:latest`. Restarting on the VPS is manual.
- **Docs:** README (setup, deployment, NPM, signing in, security), `env.example`, `CLAUDE.md`, `docs/NOTES.md` (Zoho OAuth gotchas, decisions) and `docs/ROADMAP.md` (new architecture, plus the planned design for per-browser settings).
### Verified
- End to end against the real `server.js` over a unix socket, with axios stubbed: login redirect and state cookie; callback with a forged or missing state, a refused sign-in and a bad code; cookie flags and encryption; tokens never reach the page; a tampered cookie; three concurrent calls with an expired token renewing once; a revoked refresh token; a session past `SESSION_DAYS`; an SDP 401 signing the user out; sign-out revoking the token.
- The page script with stubbed DOM and fetch: the signed-out panel, `?signin=` messages, first-load backfill with no ding, a ding for a new close, and signing out mid-session.
- **Live, bare metal behind NPM (2026-10-15):** Zoho sign-in, SDP data, Sign out, and the access list blocking mobile data.
- **Not verified yet:** token renewal after an hour, the Docker build, the Gitea workflow, the production container behind `sdp-dashboard:3000`.
## Earlier the same day (dashboard work)
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.** The screenshots it had then were removed after the auth rebuild; new ones are due.
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 in `.env`
New: `PUBLIC_URL`, `SESSION_SECRET`, `SESSION_DAYS` (optional), `TRUST_PROXY` (optional). `CLIENT_ID` and `CLIENT_SECRET` must now come from the Server-based client. Gone: `ADMIN_KEY`, `TOKEN_FILE`. Unchanged: `SITE_NAMES`, `GROUP_NAMES`, `TECHNICIAN_NAMES`, `STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`, `WORKLOAD_MINUTES`, `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.
- No screenshots in the README for now (`images/` removed); Skylar is retaking them.
- The README's reference links for Zoho's device flow were removed; links for the server-based (web) flow haven't been added.
- Each open dashboard polls SDP with its own token. That's fine for a few viewers; watch SDP rate limits if wallboards multiply.
## Next steps
Already done: Zoho Server-based client, NPM proxy hosts (`dash` and `dash-test`) with the MCCS access list, Cloudflare DNS, a wildcard certificate, and the bare-metal test.
1. **Token renewal:** keep a tab open for more than an hour and confirm it stays signed in.
2. **First image:** check the `DHUB_UNAME` and `DHUB_PASS` secrets and the runner's `self-hosted` label, then push a `v*-prod*` tag and watch the workflow.
3. **Production container:** use a production `.env` with **no `PORT` line** and `PUBLIC_URL=https://` the production subdomain, check NPM's network name, then `docker compose up -d`. Confirm `docker ps` shows it healthy and the production subdomain signs in.
4. **Screenshots** for the README.
5. **Then:** per-browser settings, as designed in `docs/ROADMAP.md` (not started).
## To pick up
Start a Claude Code session in this repo and say something like:
*"Read SESSION.md and docs/ROADMAP.md. The per-user sign-in works live on bare metal; next is the first Docker deploy. Help me check the production container, then we'll plan the per-browser settings panel."*

View File

@ -1,25 +0,0 @@
# Example compose file for the VPS. Copy src/env.example to .env next to this file and fill it in.
# Nginx Proxy Manager forwards the subdomain to sdp-dashboard:3000 and enforces the IP allow list.
services:
sdp-dashboard:
image: v0idf1sh/sdp-dashboard:latest
container_name: sdp-dashboard
restart: unless-stopped
env_file: .env
environment:
# must match the redirect URI registered in the Zoho API console, minus /auth/callback
PUBLIC_URL: https://dashboard.example.com
# No published ports on purpose. Docker's port publishing bypasses ufw, so "3000:3000" would
# let anyone reach the app directly and skip NPM's access list. NPM reaches it over the
# shared network instead, using the container name as the forward hostname.
expose:
- "3000"
networks:
- proxy
networks:
proxy:
# the network Nginx Proxy Manager's container is on; check with `docker network ls`
external: true
name: ${NPM_NETWORK:-npm_default}

View File

@ -1,44 +0,0 @@
# 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 every SDP query so far. `AaaServer.profile.READ` is added only for the signed-in user's name (`GET accounts.zoho.com/oauth/user/info`). If that call fails, sign-in still works. Changing the scopes means everyone signs in again.
## Zoho OAuth gotchas
- **Client type:** web sign-in needs a **Server-based Application** client. The old Non-Browser (device-code) client can't do redirects. The redirect URI must match `PUBLIC_URL/auth/callback` exactly.
- **Refresh tokens:** you only get one with `access_type=offline`, and `prompt=consent` makes Zoho issue one on every sign-in. Access tokens last about an hour; renew with `POST accounts.zoho.com/oauth/v2/token`, `grant_type=refresh_token`.
- **20 refresh tokens per user per client:** Zoho silently deletes the oldest when a 21st is issued. Each browser sign-in creates one, which is why Sign out revokes (`POST /oauth/v2/token/revoke?token=...`). A user signed in on more than 20 browsers would find the oldest one signed out.
- **Errors come back as HTTP 200:** a bad code or refresh token returns `{ "error": "invalid_code" }` with status 200. `auth.js` checks for `access_token` instead of relying on axios throwing.
- **Data centre:** everything points at `accounts.zoho.com` (US). The callback also gets `location` and `accounts-server` parameters, which would matter only for users in another Zoho DC.
## Deployment gotchas
- **NPM runs in Docker**, so `localhost` in a proxy host is NPM's own container. For a bare-metal test run, forward to the gateway of NPM's Docker network and allow that subnet through ufw. The production container is reached by name (`sdp-dashboard:3000`) because it joins NPM's network.
- **Port 3000 is Gitea's on the VPS.** Bare-metal test runs use `PORT=3005`. Never put `PORT` in the production `.env`; the container listens on 3000, and its health check and the NPM host expect that.
- **Cloudflare proxy and the NPM access list:** a proxied record can make NPM see Cloudflare's IPs, so the allow list blocks everyone. The fix is DNS only, or `real_ip_header CF-Connecting-IP;` in the proxy host's Advanced config. The live test on 2026-10-15 worked with the access list (an MCCS-listed IP got in, mobile data got a 403).
## 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:** **Per-user Zoho sign-in replaces the shared service token.** This reverses the earlier "one service token plus region config" decision, and drops the `ADMIN_KEY` and `data/token.json` from phase 1. Leadership's concern was that anyone who reached the page could see data while a shared token was live. Now every request needs the viewer's own sign-in, and NPM limits the site to MCCS addresses. The cost is that each open dashboard polls SDP itself; acceptable for a small team.
- **2026-10-15:** **Sessions in an encrypted cookie, not a server store.** AES-256-GCM with a key from `SESSION_SECRET`, `HttpOnly`, `SameSite=Lax` (Lax so the cookie survives the redirect back from Zoho), and `Secure` when `PUBLIC_URL` is https. The container needs no volume or database, and restarts don't sign anyone out. Renewing the access token rewrites the cookie but keeps `startedAt`, so `SESSION_DAYS` is a hard limit. Concurrent calls with an expired token share one renewal (`renewals` map in `session.js`).
- **2026-10-15:** **The browser runs the checks** and keeps the closed feed (first load asks for 12 hours, then 10-minute windows every minute). The server has no per-user timers or state. This also suits per-browser settings later: the browser will send its settings with each call.
- **2026-10-15:** **No published ports in compose.** Docker's port publishing bypasses ufw, which would skip NPM's IP access list. The container joins NPM's network instead.
- **2026-10-15:** **CI uses plain `docker` commands** on the self-hosted runner, not the buildx or login actions, so there are fewer moving parts on the runner. Pushes both the tag and `latest`.
## 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 with per-browser settings.
- No screenshots: `images/` was removed on 2026-10-15 and new ones are due.

View File

@ -1,42 +0,0 @@
# Roadmap
## Goal
A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zoho account and sees the queue for their region. Each browser can be customised, and new panels are easy to add.
## Current architecture
- **Hosting:** Docker image on a VPS behind Nginx Proxy Manager, which only lets in MCCS' public IP blocks. The image is built by Gitea Actions; the container is restarted by hand.
- **Identity and data:** each user signs in with their own Zoho account (web OAuth, Server-based Application client), and every SDP query runs with that user's token. What a user sees is limited by their own SDP permissions and then by the region filter.
- **Sessions:** the Zoho tokens live in an encrypted, `HttpOnly` cookie. The server stores nothing, so restarts and upgrades don't sign anyone out.
- **Polling:** the browser runs the checks (closed feed every minute, status table every `WORKLOAD_MINUTES`) and keeps the closed feed. The server answers each call with the caller's token and caches nothing per user. Only the weather is shared.
- **Region:** central region only, from `.env` (`SITE_NAMES`, `GROUP_NAMES`, `TECHNICIAN_NAMES` and the status lists).
## Next
- [ ] **Finish checking the rebuild live.** Done on 2026-10-15 with a bare-metal test run behind `dash-test`: Zoho sign-in, live SDP data, Sign out, and the NPM access list (403 from mobile data). Still to check: the token renews after an hour without a sign-in prompt, and the first `v*-prod*` image builds, starts healthy and serves the production subdomain.
- [ ] **Per-browser settings** (planned, not built). A settings panel, or a setup prompt on first launch, that saves choices in the browser (`localStorage`): sites, groups, technicians, which statuses count as closed, unassigned exclusions, sound, hidden columns. Design notes:
- `/api/session` already returns the `.env` region as `settings`. The panel starts from those defaults, and "reset" goes back to them.
- The page would send its settings with each `/api/closed` and `/api/workload` call (query string or a JSON header). `routes.js` currently passes the server's `settings` object to `tickets.js`. That becomes "defaults merged with the validated request", and `tickets.js` needs no change because it already takes these as arguments.
- The server must validate what the browser sends (lists of plain names, length caps, minimum refresh intervals). That's to protect SDP and the server, not for access control: data comes from the user's own token, so changing the region can never show more than SDP already lets that user see.
- Pick lists for sites, groups and technicians could come from SDP, but that probably needs another scope (check which, e.g. a setup or users read scope). Adding a scope means everyone signs in again.
- Export and import settings as JSON, so a region's setup can be shared without a server-side store.
- Weather location (`OWM_ZIP_CODE`) could become a setting too. The server cache would then need to be keyed by zip.
- [ ] **Panel modules:** each panel has a server route to fetch its data and page code to draw it. The weather card is the model.
- [ ] **Status table width:** it scrolls sideways once there are more statuses than fit (see `NOTES.md`).
## Open questions
- Does SDP restrict technicians to certain sites? With per-user tokens, that decides whether someone in another region sees their region's tickets or nothing.
- How many dashboards will be open at once? Each one polls SDP with its own token, so check the SDP API rate limits if wallboards multiply.
## 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`).
- Server-based auth rebuild (2026-10-15): per-user Zoho web sign-in, sessions in an encrypted browser cookie, a stateless server, and the browser running the checks. This replaced the shared device-code token, the `ADMIN_KEY` and `data/token.json` from the short-lived phase 1.
- Docker image `v0idf1sh/sdp-dashboard`, an example `docker-compose.yml`, and a Gitea Actions build on `v*-prod*` tags.
- Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM.

BIN
images/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
images/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
images/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
images/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

24
package-lock.json generated
View File

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

View File

@ -1,27 +1,22 @@
{
"name": "sdp-request-watcher",
"version": "1.0.0",
"description": "A hosted help desk dashboard for ManageEngine ServiceDesk Plus Cloud, with per-user Zoho sign-in.",
"description": "A local NodeJS app and web server to watch Zoho ManageEngine SDP via API.",
"repository": {
"type": "git",
"url": "https://git.vfsh.dev/sgrant/sdp-request-watcher.git"
},
"license": "MIT",
"license": "ISC",
"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.x",
"dotenv": "18.x",
"express": "5.x"
"axios": "^1.13.2",
"dotenv": "^17.4.2",
"express": "^5.2.1"
},
"devDependencies": {}
}

View File

@ -1,90 +1,80 @@
const axios = require('axios');
// MCCS is on Zoho's US data centre; users on another DC would need accounts.zoho.<tld>
const ACCOUNTS_URL = 'https://accounts.zoho.com';
// profile.READ is only for showing who's signed in; the dashboard itself needs requests.READ
const SCOPES = 'SDPOnDemand.requests.READ,AaaServer.profile.READ';
async function getDeviceCode(clientId) {
const params = new URLSearchParams({
client_id: clientId,
scope: 'SDPOnDemand.requests.READ',
grant_type: 'device_request',
access_type: 'offline'
});
// zoho reports most failures as a 200 with an error field, not an http error
function zohoError(data, fallback) {
const err = new Error('Zoho: ' + (data?.error || fallback));
err.zoho = true;
return err;
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/code', params);
return res.data;
}
function createZohoAuth({ clientId, clientSecret, redirectUri }) {
// the page Zoho shows to approve the dashboard; state ties the callback to this browser
function authorizeUrl(state) {
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
scope: SCOPES,
redirect_uri: redirectUri,
state,
// offline is what gets us a refresh token; consent makes zoho issue one every time
access_type: 'offline',
prompt: 'consent'
});
return `${ACCOUNTS_URL}/oauth/v2/auth?${params}`;
}
function pollForToken(clientId, clientSecret, deviceCode, interval, expiresIn) {
const pollParams = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code: deviceCode,
grant_type: 'device_token'
});
async function tokenRequest(fields) {
const params = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, ...fields });
const res = await axios.post(`${ACCOUNTS_URL}/oauth/v2/token`, params);
if (!res.data?.access_token) throw zohoError(res.data, 'no access token');
return res.data;
}
return new Promise((resolve, reject) => {
const deadline = Date.now() + (expiresIn || 300) * 1000;
async function exchangeCode(code) {
const data = await tokenRequest({ grant_type: 'authorization_code', redirect_uri: redirectUri, code });
if (!data.refresh_token) throw zohoError(data, 'no refresh token');
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + (data.expires_in || 3600) * 1000
};
}
const timer = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(timer);
reject(new Error('Expired try again.'));
return;
}
// access tokens last about an hour; the refresh token lasts until revoked
async function refreshAccessToken(refreshToken) {
const data = await tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken });
return {
accessToken: data.access_token,
expiresAt: Date.now() + (data.expires_in || 3600) * 1000
};
}
try {
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);
}
} catch (err) {
}
}, interval + 500);
});
}
// zoho keeps at most 20 refresh tokens per user per client and silently drops the oldest,
// so revoke on sign-out rather than leaving them to pile up
async function revokeToken(refreshToken) {
await axios.post(`${ACCOUNTS_URL}/oauth/v2/token/revoke`, new URLSearchParams({ token: refreshToken }));
}
async function startAuthentication({ clientId, clientSecret, state }) {
state.auth = 'pending';
state.lastError = null;
async function getUser(accessToken) {
try {
const res = await axios.get(`${ACCOUNTS_URL}/oauth/user/info`, {
headers: { Authorization: `Zoho-oauthtoken ${accessToken}` }
});
return {
name: res.data.Display_Name || [res.data.First_Name, res.data.Last_Name].filter(Boolean).join(' ') || null,
email: res.data.Email || null
};
} catch (err) {
// only cosmetic; the page just says "Signed in" without a name
console.warn('Could not read Zoho profile:', err.response?.data || err.message);
return { name: null, email: null };
}
}
const deviceData = await getDeviceCode(clientId);
state.verificationUrl = deviceData.verification_uri_complete;
state.userCode = deviceData.user_code;
return {
authorizeUrl,
exchangeCode,
refreshAccessToken,
revokeToken,
getUser
};
console.log('Open this URL and authorize:', deviceData.verification_uri_complete);
console.log('Code: ', deviceData.user_code);
try {
const accessToken = await pollForToken(
clientId,
clientSecret,
deviceData.device_code,
deviceData.interval,
deviceData.expires_in
);
state.auth = 'connected';
state.verificationUrl = null;
state.userCode = null;
return { accessToken };
} catch (err) {
state.auth = 'error';
state.lastError = err.message;
state.verificationUrl = null;
state.userCode = null;
return null;
}
}
module.exports = {
createZohoAuth
startAuthentication
};

89
src/boardService.js Normal file
View File

@ -0,0 +1,89 @@
const { startAuthentication } = require('./auth');
const { fetchClosedTickets } = require('./tickets');
function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes }) {
const state = {
auth: 'disconnected',
verificationUrl: null,
userCode: null,
lastCheck: null,
lastError: null
};
const tickets = new Map();
let accessToken = null;
let pollingTimer = null;
async function refreshTickets() {
if (!accessToken) return;
await fetchClosedTickets({
accessToken,
state,
tickets,
technicians,
statusSet,
windowMinutes
});
}
function startPolling() {
if (pollingTimer) return;
pollingTimer = setInterval(() => {
refreshTickets().catch(err => {
state.lastError = err.message;
state.lastCheck = Date.now();
});
}, checkMinutes * 60 * 1000);
}
async function connect() {
const authResult = await startAuthentication({
clientId,
clientSecret,
state
});
if (!authResult?.accessToken) return;
accessToken = authResult.accessToken;
console.log('Authorization successful');
await refreshTickets();
startPolling();
}
function failConnection(err) {
state.auth = 'error';
state.lastError = err.message;
state.verificationUrl = null;
state.userCode = null;
}
function getState() {
return {
...state,
windowMinutes,
checkMinutes,
watching: technicians,
serverTime: Date.now(),
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
};
}
async function checkNow() {
await refreshTickets();
}
return {
connect,
checkNow,
failConnection,
getState
};
}
module.exports = {
createBoardService
};

View File

@ -1,22 +1,4 @@
# Zoho API console "Server-based Application" client. Its redirect URI must be PUBLIC_URL/auth/callback
CLIENT_ID=
CLIENT_SECRET=
# the address people open the dashboard at (no trailing slash); defaults to http://localhost:3000
PUBLIC_URL=https://dashboard.example.com
# encrypts the sign-in cookie; at least 32 characters, e.g. from `openssl rand -hex 32`. Changing it signs everyone out
SESSION_SECRET=
# optional: days before a sign-in expires and Zoho is asked again (default 7)
SESSION_DAYS=7
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

View File

@ -3,20 +3,19 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Help desk dashboard</title>
<meta name="author" content="Skylar Grant, Isaac Godin (design), Claude by Anthropic">
<title>Closed ticket board</title>
<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=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<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">
<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%);
@ -28,7 +27,6 @@
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
@ -44,11 +42,10 @@ body {
}
.wrap {
width: 100%;
max-width: 1320px;
max-width: 860px;
flex: 1;
margin: 0 auto;
padding: 28px 24px 48px;
padding: 32px 20px 80px;
}
/* ---- header ---- */
@ -56,7 +53,7 @@ body {
header {
display: flex;
flex-wrap: wrap;
align-items: center;
align-items: baseline;
gap: 12px 20px;
padding-bottom: 14px;
border-bottom: 2px solid var(--ink);
@ -74,6 +71,7 @@ h1 {
display: flex;
align-items: center;
gap: 7px;
margin-left: auto;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
@ -98,13 +96,6 @@ h1 {
50% { opacity: 0.25; }
}
.actions {
display: flex;
align-items: center;
gap: 16px;
margin-left: auto;
}
.meta {
display: flex;
flex-wrap: wrap;
@ -133,13 +124,18 @@ h1 {
.panel p {
margin: 0 0 16px;
color: var(--muted);
max-width: 80ch;
max-width: 52ch;
}
.user {
.code {
display: inline-block;
margin-bottom: 16px;
padding: 10px 16px;
background: var(--signal-soft);
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
font-size: 26px;
font-weight: 600;
letter-spacing: 0.12em;
}
button, .button {
@ -169,40 +165,6 @@ 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;
@ -213,188 +175,55 @@ button.quiet:disabled { opacity: 0.5; cursor: default; }
}
.error strong { display: block; font-family: var(--mono); font-size: 12px; color: var(--alert); }
/* ---- stat tiles ---- */
/* ---- tallies ---- */
.stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
.tallies {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 24px;
}
.stat {
padding: 16px 18px;
background: var(--card);
border: 1px solid var(--rule);
}
.stat .label {
font-family: var(--mono);
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;
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 {
.tally {
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;
gap: 8px;
padding: 5px 12px;
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);
border: 1px solid var(--rule);
font-size: 13px;
}
.tally b {
font-family: var(--mono);
font-size: 12px;
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);
color: var(--signal);
}
/* ---- feed ---- */
.feed {
max-height: 640px;
overflow-y: auto;
}
.feed { margin-top: 20px; }
.ticket {
--age: 0;
display: grid;
grid-template-columns: 66px 1fr;
border-bottom: 1px solid var(--rule);
grid-template-columns: 74px 1fr;
background: var(--card);
border: 1px solid var(--rule);
border-bottom: none;
}
.feed .ticket:last-child { border-bottom: none; }
.feed .ticket:last-child { border-bottom: 1px solid var(--rule); }
/* how long ago this closed, drained of color as it ages out of the hour */
/* The rail is the point of the whole page: how long ago this closed, drained
of color as it ages out of the window. */
.rail {
display: flex;
flex-direction: column;
justify-content: center;
padding: 12px 0 12px 12px;
padding: 14px 0 14px 14px;
border-left: 3px solid color-mix(in oklab, var(--signal), var(--rule) calc(var(--age) * 100%));
font-family: var(--mono);
font-size: 14px;
font-size: 15px;
font-weight: 500;
color: color-mix(in oklab, var(--signal), var(--muted) calc(var(--age) * 100%));
}
@ -406,11 +235,11 @@ tr.flash td { animation: flash 2.4s ease-out; }
color: var(--muted);
}
.body { padding: 12px 14px 12px 4px; min-width: 0; }
.body { padding: 14px 16px 14px 4px; min-width: 0; }
.subject {
margin: 0;
font-size: 14px;
font-size: 15px;
font-weight: 500;
overflow-wrap: anywhere;
}
@ -422,8 +251,8 @@ tr.flash td { animation: flash 2.4s ease-out; }
}
.byline {
margin: 2px 0 0;
font-size: 12px;
margin: 3px 0 0;
font-size: 13px;
color: var(--muted);
}
.byline b { color: var(--ink); font-weight: 500; }
@ -435,103 +264,32 @@ tr.flash td { animation: flash 2.4s ease-out; }
}
.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; }
body > footer {
width: 100%;
max-width: 1320px;
margin: auto auto 0;
padding: 0 24px 28px;
footer {
margin-top: 28px;
font-family: var(--font-ui);
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; }
color: var(--ink);
}
@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; }
body > footer {
margin-top: auto;
max-width: 860px;
width: 100%;
padding: 0 20px 28px;
}
@media (max-width: 520px) {
.ticket { grid-template-columns: 62px 1fr; }
.rail { font-size: 13px; padding-left: 10px; }
}
@media (prefers-reduced-motion: reduce) {
@ -543,23 +301,17 @@ body > footer p + p { margin-top: 4px; }
<div class="wrap">
<header>
<h1>Help desk dashboard</h1>
<div class="status" id="status" data-auth="loading">
<span class="dot"></span><span id="statusText">Checking sign-in…</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>
<span class="user" id="user" hidden></span>
<button class="quiet" id="signOutBtn" onclick="signOut(this)" hidden>Sign out</button>
<h1>Closed ticket board</h1>
<div class="status" id="status" data-auth="disconnected">
<span class="dot"></span><span id="statusText">Not connected</span>
</div>
</header>
<div class="meta" id="meta"></div>
<div id="main"></div>
<footer id="footer"></footer>
</div>
<script>
@ -575,47 +327,17 @@ function beep() {
});
}
// the ding is on by default; muting is remembered per browser
let soundOn = true;
try { soundOn = localStorage.getItem('sdp-sound') !== 'off'; } catch (err) {}
function toggleSound() {
soundOn = !soundOn;
try { localStorage.setItem('sdp-sound', soundOn ? 'on' : 'off'); } catch (err) {}
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
if (soundOn) beep();
}
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
let data = {
auth: 'loading', // loading, connected or disconnected (signed out)
tickets: [],
workload: {},
unassigned: {},
lastCheck: null,
lastError: null
};
let data = null;
let seen = new Set();
let firstLoad = true;
let flashTechs = new Set();
// the browser builds the closed feed now; the server only answers each check with the user's token
const feed = new Map();
let feedReady = false;
let timers = [];
let signInProblem = null;
const STATUS_TEXT = {
loading: 'Checking sign-in…',
disconnected: 'Signed out',
connected: 'Live'
};
// what /auth/callback means when it sends someone back with ?signin=...
const SIGNIN_PROBLEMS = {
denied: 'Zoho sign-in was cancelled or refused.',
expired: 'The sign-in took too long or was started in another tab. Try again.',
failed: "Zoho sign-in didn't complete. Try again, and check the server log if it keeps happening."
disconnected: 'Not connected',
pending: 'Waiting for authorization',
connected: 'Live',
error: 'Stopped'
};
function elapsed(ms) {
@ -634,320 +356,82 @@ function esc(text) {
return String(text ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
// every call carries the session cookie; a 401 means the sign-in is gone or was revoked
async function api(url, options) {
const res = await fetch(url, { credentials: 'same-origin', ...options });
if (res.status === 401) {
signedOut();
throw new Error('Signed out');
}
const body = await res.json();
if (!res.ok) throw new Error(body.error || 'Request failed (' + res.status + ')');
return body;
}
function signedOut() {
timers.forEach(clearInterval);
timers = [];
data.auth = 'disconnected';
render();
}
async function start() {
const params = new URLSearchParams(location.search);
if (params.has('signin')) {
signInProblem = SIGNIN_PROBLEMS[params.get('signin')] || SIGNIN_PROBLEMS.failed;
history.replaceState(null, '', location.pathname);
}
let session;
async function load() {
try {
session = await api('/api/session');
const res = await fetch('/api/state');
data = await res.json();
render();
} catch (err) {
if (data.auth === 'disconnected') return;
$('statusText').textContent = 'Server unreachable';
setTimeout(start, 15000);
return;
}
Object.assign(data, session, { auth: 'connected' });
render();
checkClosed();
checkWorkload();
loadWeather();
timers.push(
setInterval(checkClosed, data.checkMinutes * 60 * 1000),
setInterval(checkWorkload, data.workloadMinutes * 60 * 1000),
setInterval(loadWeather, 10 * 60 * 1000) // server caches for 10 min, no point asking sooner
);
}
async function checkClosed() {
// the whole feed on first load, then a short window that overlaps the last check
const minutes = feedReady ? data.windowMinutes : data.feedHours * 60;
try {
const result = await api('/api/closed?minutes=' + minutes);
for (const t of result.tickets) feed.set(String(t.id), t);
const cutoff = Date.now() - data.feedHours * 60 * 60 * 1000;
for (const [id, t] of feed) {
if (t.closedAt < cutoff) feed.delete(id);
}
data.tickets = [...feed.values()].sort((a, b) => b.closedAt - a.closedAt);
data.lastCheck = result.checkedAt;
data.lastError = result.error;
if (!result.error) feedReady = true;
} catch (err) {
if (data.auth !== 'connected') return;
data.lastError = err.message;
data.lastCheck = Date.now();
}
render();
}
async function checkWorkload() {
try {
const result = await api('/api/workload');
data.workload = result.workload;
data.unassigned = result.unassigned;
} catch (err) {
if (data.auth !== 'connected') return;
data.workload = { ...data.workload, error: err.message, updatedAt: Date.now() };
}
render();
}
function render() {
// 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();
if (!data) return;
if (!firstLoad && data.tickets.some(t => !seen.has(t.id))) beep(); //Calls Beep
$('status').dataset.auth = data.auth;
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
const live = data.auth === 'connected';
$('checkBtn').hidden = !live;
$('signOutBtn').hidden = !live;
$('user').hidden = !live;
const user = data.user || {};
$('user').textContent = user.name || user.email || 'Signed in';
$('user').title = user.email || '';
const wl = data.workload || {};
const s = data.settings || { sites: [], groups: [], technicians: [] };
$('meta').innerHTML = !live ? '' : [
'Closed tickets checked every ' + data.checkMinutes + ' min',
'Status counts every ' + data.workloadMinutes + ' min',
s.technicians.length ? 'Watching ' + s.technicians.length + ' technicians' : 'Watching all technicians',
s.sites.length ? 'Sites: ' + s.sites.join(', ') : '',
s.groups.length ? 'Groups: ' + s.groups.join(', ') : '',
$('meta').innerHTML = [
'Checks every ' + data.checkMinutes + ' min',
'Window ' + data.windowMinutes + ' min',
data.watching.length ? 'Watching ' + data.watching.length + ' technicians' : 'Watching all technicians',
data.lastCheck ? 'Last check ' + clockTime(data.lastCheck) : ''
].filter(Boolean).map(t => '<span>' + esc(t) + '</span>').join('');
let html = '';
if (data.auth === 'disconnected') {
html += `${signInProblem ? `<div class="error"><strong>Not signed in</strong>${esc(signInProblem)}</div>` : ''}
<div class="panel">
<h2>Sign in to the help desk dashboard</h2>
<p>Sign in with your own Zoho account, the one you use for ServiceDesk Plus. The dashboard
only reads requests. Your sign-in stays in this browser until you sign out or it expires.</p>
<a class="button" href="/auth/login">Sign in with Zoho</a>
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
page and starts the checks as soon as you approve.</p>
<button onclick="connect(this)">Connect</button>
</div>`;
}
if (data.auth === 'pending') {
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>
</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') {
html += renderStats(wl);
html += `<div class="board">
${renderWorkload(wl)}
<div class="side">
${renderWeather()}
${renderFeed()}
</div>
</div>`;
html += renderUnassignedList(data.unassigned || {});
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>';
}
// 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;
for (const t of data.tickets) seen.add(t.id);
// stay quiet until the first full feed has drawn, so a page load doesn't ding for old closes
if (feedReady) 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>`
$('footer').textContent = data.tickets && data.tickets.length
? data.tickets.length + ' tickets on the board'
: '';
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.feedHours}h</span></div>
${list}
</section>`;
firstLoad = false;
}
function renderTicket(ticket) {
@ -956,6 +440,7 @@ 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">
@ -969,41 +454,27 @@ function renderTicket(ticket) {
</article>`;
}
async function connect(btn) {
btn.disabled = true;
btn.textContent = 'Connecting...';
await fetch('/api/connect', { method: 'POST' });
load();
}
async function checkNow(btn) {
btn.disabled = true;
btn.textContent = 'Refreshing...';
try {
await Promise.all([checkClosed(), checkWorkload()]);
} finally {
btn.disabled = false;
btn.textContent = 'Refresh now';
}
btn.textContent = 'Checking...';
await fetch('/api/check', { method: 'POST' });
load();
}
async function signOut(btn) {
btn.disabled = true;
try {
await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' });
} finally {
location.reload();
}
}
let weather = null;
async function loadWeather() {
try {
weather = await api('/api/weather');
render();
} catch (err) {}
}
start();
setInterval(render, 20000); // re-age the rails between checks
load();
setInterval(load, 5000); // pull fresh state from the server
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,141 +1,37 @@
const express = require('express');
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
// Sign-in: Zoho's web OAuth flow. Every user approves with their own Zoho account.
function createAuthRoutes({ zoho, sessions }) {
function createRoutes(boardService) {
const router = express.Router();
router.get('/login', (req, res) => {
res.redirect(zoho.authorizeUrl(sessions.startState(res)));
router.get('/state', (req, res) => {
res.json(boardService.getState());
});
router.get('/callback', async (req, res) => {
if (!sessions.checkState(req, res, req.query.state)) {
return res.redirect('/?signin=expired');
}
if (req.query.error || !req.query.code) {
console.warn('Sign-in refused by Zoho:', req.query.error || 'no code');
return res.redirect('/?signin=denied');
router.post('/connect', async (req, res) => {
const currentState = boardService.getState();
if (currentState.auth === 'pending' || currentState.auth === 'connected') {
return res.json({ ok: true });
}
try {
const tokens = await zoho.exchangeCode(String(req.query.code));
const user = await zoho.getUser(tokens.accessToken);
sessions.start(res, tokens, user);
console.log(`Signed in: ${user.email || user.name || 'unknown user'} from ${req.ip}`);
res.redirect('/');
boardService.connect().catch(err => {
boardService.failConnection(err);
});
res.json({ ok: true });
} catch (err) {
console.error('Sign-in failed:', err.response?.data || err.message);
res.redirect('/?signin=failed');
boardService.failConnection(err);
res.status(500).json({ ok: false, error: err.message });
}
});
router.post('/logout', async (req, res) => {
const session = sessions.read(req);
sessions.clear(res);
if (session) {
console.log(`Signed out: ${session.user?.email || session.user?.name || 'unknown user'}`);
await zoho.revokeToken(session.refreshToken).catch(err => {
console.warn('Could not revoke Zoho token:', err.message);
});
}
router.post('/check', async (req, res) => {
await boardService.checkNow();
res.json({ ok: true });
});
return router;
}
// Data: each call runs against SDP with the caller's own token. Nothing is cached per user,
// so the browser decides how often to ask (see checkMinutes and workloadMinutes).
function createApiRoutes({ sessions, weatherService, settings, timing }) {
const router = express.Router();
router.use((req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
});
router.use(sessions.requireSession);
// turns an SDP 401 thrown by tickets.js into a sign-out
const signedOutOnSdp401 = handler => async (req, res) => {
try {
await handler(req, res);
} catch (err) {
if (err.response?.status !== 401) throw err;
sessions.clear(res);
res.status(401).json({ error: 'ServiceDesk Plus rejected your sign-in' });
}
};
router.get('/session', (req, res) => {
res.json({
user: req.session.user,
sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000,
// the region filters every query uses; a future settings panel would start from these
settings: {
sites: settings.scope.sites,
groups: settings.scope.groups,
technicians: settings.technicians
},
checkMinutes: timing.checkMinutes,
windowMinutes: timing.windowMinutes,
workloadMinutes: timing.workloadMinutes,
feedHours: timing.feedHours
});
});
router.get('/closed', signedOutOnSdp401(async (req, res) => {
// the page asks for the full feed on first load, then a short overlapping window each check
const minutes = Math.min(Math.max(Number(req.query.minutes) || timing.windowMinutes, 1), timing.feedHours * 60);
const state = { lastCheck: null, lastError: null };
const tickets = new Map();
await fetchClosedTickets({
accessToken: req.session.accessToken,
state,
tickets,
technicians: settings.technicians,
statusSet: settings.statusSet,
scope: settings.scope,
windowMinutes: minutes
});
res.json({
checkedAt: state.lastCheck,
error: state.lastError,
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
});
}));
router.get('/workload', signedOutOnSdp401(async (req, res) => {
const workload = { updatedAt: null, error: null, truncated: false, statuses: [], technicians: [] };
const unassigned = { updatedAt: null, error: null, truncated: false, count: 0, discarded: 0, tickets: [] };
await fetchWorkload({
accessToken: req.session.accessToken,
workload,
technicians: settings.technicians,
closedStatuses: settings.closedStatuses,
scope: settings.scope
});
await fetchUnassigned({
accessToken: req.session.accessToken,
unassigned,
excludedStatuses: settings.unassignedExcludedStatuses,
scope: settings.scope
});
res.json({ workload, unassigned });
}));
router.get('/weather', async (req, res) => {
res.json(await weatherService.getWeather());
});
return router;
}
module.exports = {
createAuthRoutes,
createApiRoutes
createRoutes
};

View File

@ -1,98 +1,43 @@
// 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 });
const path = require('path');
const express = require('express');
const { createZohoAuth } = require('./auth');
const { createSessions } = require('./session');
const { createAuthRoutes, createApiRoutes } = require('./routes');
const { createWeatherService } = require('./weather');
const { createBoardService } = require('./boardService');
const { createRoutes } = require('./routes');
const { CLIENT_ID, CLIENT_SECRET, SESSION_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env;
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env;
const PORT = process.env.PORT || 3000; // default 3000
// the address people open; zoho sends them back to PUBLIC_URL/auth/callback after sign-in
const PUBLIC_URL = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
const problems = [
!CLIENT_ID || !CLIENT_SECRET ? 'CLIENT_ID and CLIENT_SECRET are required' : null,
!SESSION_SECRET || SESSION_SECRET.length < 32 ? 'SESSION_SECRET must be at least 32 characters (try: openssl rand -hex 32)' : null
].filter(Boolean);
if (problems.length) {
problems.forEach(p => console.error(p));
if (!CLIENT_ID || !CLIENT_SECRET) {
console.error('Client id and secret missing from .env');
process.exit(1);
}
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
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 statusSet = new Set(statuses.map(s => s.toLowerCase()));
const technicians = toList(TECHNICIAN_NAMES);
const statuses = toList(STATUS_NAMES);
// statuses that count as done; everything else shows up in the workload table
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
// the region, applied to every query for every user. Kept server side for now; per-browser
// settings (see docs/ROADMAP.md) would override these within limits the server checks.
const settings = {
technicians,
statusSet: new Set(statuses.map(s => s.toLowerCase())),
closedStatuses,
// statuses the unassigned counter ignores, to match the ManageEngine view
unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
: closedStatuses,
// restricts every query to these sites/groups; empty means no restriction
scope: { sites: toList(SITE_NAMES), groups: toList(GROUP_NAMES) }
};
const timing = {
windowMinutes: 10, // how far back each closed check looks, in minutes
checkMinutes: 1, // how often the page checks for newly closed tickets, in minutes
workloadMinutes: Number(process.env.WORKLOAD_MINUTES) || 5, // how often the page recounts the status table, in minutes
feedHours: 12, // how long a closed ticket stays in the feed
sessionDays: Number(process.env.SESSION_DAYS) || 7 // how long a sign-in lasts before Zoho is asked again
};
const zoho = createZohoAuth({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
redirectUri: `${PUBLIC_URL}/auth/callback`
});
const sessions = createSessions({
secret: SESSION_SECRET,
// the proxy terminates https, so go by the public address rather than the request
secure: PUBLIC_URL.startsWith('https://'),
sessionDays: timing.sessionDays,
zoho
});
// weather widget is optional; it stays hidden unless both values are set
const weatherService = createWeatherService({ apiKey: OWM_API_KEY, zipCode: OWM_ZIP_CODE });
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 app = express();
app.disable('x-powered-by');
// behind nginx proxy manager: believe X-Forwarded-For from private addresses (the docker network)
// so logs show the real client ip
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback, linklocal, uniquelocal');
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'same-origin'
});
next();
//creates the board service with the config specified
const boardService = createBoardService({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
technicians,
statusSet,
windowMinutes: WINDOW_MINUTES,
checkMinutes: CHECK_MINUTES
});
// for the docker healthcheck; says nothing about sign-in or data
app.get('/healthz', (req, res) => res.send('ok'));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/auth', createAuthRoutes({ zoho, sessions }));
app.use('/api', createApiRoutes({ sessions, weatherService, settings, timing }));
app.use('/api', createRoutes(boardService));
//starts listening on port specified
app.listen(PORT, () => {
console.log(`Listening on port ${PORT}, public address ${PUBLIC_URL}`);
console.log(`Open http://localhost:${PORT}`);
});

View File

@ -1,146 +0,0 @@
const crypto = require('crypto');
const COOKIE = 'sdp_session';
const STATE_COOKIE = 'sdp_oauth_state';
// renew this long before expiry so a slow SDP query never runs on a dead token
const RENEW_EARLY_MS = 5 * 60 * 1000;
function parseCookies(header) {
const cookies = {};
for (const part of (header || '').split(';')) {
const eq = part.indexOf('=');
if (eq < 1) continue;
try {
cookies[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim());
} catch (err) {
// a malformed cookie from somewhere else on the domain; ignore it
}
}
return cookies;
}
// The whole session lives in the browser: the user's Zoho tokens, encrypted and authenticated
// with SESSION_SECRET, in an httpOnly cookie page scripts can't read. The server keeps nothing,
// so restarts and new image versions don't sign anyone out.
function createSessions({ secret, secure, sessionDays, zoho }) {
const key = crypto.createHash('sha256').update(secret).digest();
const maxAgeMs = sessionDays * 24 * 60 * 60 * 1000;
const cookieBase = { httpOnly: true, secure, sameSite: 'lax', path: '/' };
function seal(payload) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const body = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64url');
}
function open(value) {
try {
const raw = Buffer.from(value, 'base64url');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, raw.subarray(0, 12));
decipher.setAuthTag(raw.subarray(12, 28));
const body = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]);
return JSON.parse(body.toString('utf8'));
} catch (err) {
// tampered, truncated, or sealed with an old SESSION_SECRET
return null;
}
}
function write(res, session) {
// the cookie dies with the session; renewing the access token doesn't extend it
const maxAge = Math.max(0, session.startedAt + maxAgeMs - Date.now());
res.cookie(COOKIE, seal(session), { ...cookieBase, maxAge });
}
function clear(res) {
res.clearCookie(COOKIE, cookieBase);
}
function read(req) {
const session = open(parseCookies(req.headers.cookie)[COOKIE] || '');
if (!session || !session.refreshToken) return null;
if (Date.now() > session.startedAt + maxAgeMs) return null;
return session;
}
function start(res, tokens, user) {
const session = { ...tokens, user, startedAt: Date.now() };
write(res, session);
return session;
}
// A page load fires several calls at once, all carrying the same expired cookie. Renew once
// per refresh token and hand the result to all of them, rather than minting a token each.
const renewals = new Map();
function renew(refreshToken) {
const id = crypto.createHash('sha256').update(refreshToken).digest('hex');
const cached = renewals.get(id);
if (cached) return cached;
const pending = zoho.refreshAccessToken(refreshToken);
renewals.set(id, pending);
pending.then(
// keep the answer until shortly before it too needs renewing, then forget it
tokens => setTimeout(() => renewals.delete(id), Math.max(0, tokens.expiresAt - RENEW_EARLY_MS - Date.now())).unref(),
() => renewals.delete(id)
);
return pending;
}
// middleware: puts the signed-in session on req.session, renewing the access token if due
async function requireSession(req, res, next) {
const session = read(req);
if (!session) {
clear(res);
return res.status(401).json({ error: 'Not signed in' });
}
if (Date.now() >= session.expiresAt - RENEW_EARLY_MS) {
try {
Object.assign(session, await renew(session.refreshToken));
write(res, session);
} catch (err) {
if (err.zoho || err.response?.status === 400) {
// the refresh token was revoked or has been dropped by zoho; sign in again
clear(res);
return res.status(401).json({ error: 'Your Zoho sign-in has expired' });
}
return res.status(502).json({ error: 'Could not reach Zoho: ' + err.message });
}
}
req.session = session;
next();
}
// one-time value that ties Zoho's callback to the browser that started the sign-in
function startState(res) {
const state = crypto.randomBytes(16).toString('hex');
res.cookie(STATE_COOKIE, state, { ...cookieBase, maxAge: 10 * 60 * 1000 });
return state;
}
function checkState(req, res, given) {
const expected = parseCookies(req.headers.cookie)[STATE_COOKIE];
res.clearCookie(STATE_COOKIE, cookieBase);
if (!expected || typeof given !== 'string') return false;
// hash first so the comparison is fixed-length whatever the query string holds
const hash = value => crypto.createHash('sha256').update(value).digest();
return crypto.timingSafeEqual(hash(expected), hash(given));
}
return {
read,
start,
clear,
requireSession,
startState,
checkState
};
}
module.exports = {
createSessions
};

View File

@ -1,9 +1,5 @@
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;
@ -18,126 +14,47 @@ function getRelevantTicketTime(ticket) {
|| null;
}
// the user's token was rejected; let the route sign them out instead of showing it as a data error
function rethrowIfSignedOut(err) {
if (err.response?.status === 401) throw err;
}
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 }) {
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, windowMinutes }) {
if (!accessToken) return;
try {
const windowStart = Date.now() - windowMinutes * 60 * 1000;
// paged, because the page asks for the last 12 hours on first load, not just a few minutes
const data = await getAllRequests(accessToken, {
sort_field: 'last_updated_time',
sort_order: 'desc',
fields_required: [
'display_id',
'subject',
'status',
'completed_time',
'resolved_time',
'last_updated_time',
'technician',
'site',
'group'
],
search_criteria: allOf(
{
field: 'last_updated_time',
condition: 'greater than',
value: windowStart.toString()
},
scopeCriteria(scope)
)
}, 'closed feed');
const listInfo = {
list_info: {
row_count: 200,
sort_field: 'last_updated_time',
sort_order: 'desc',
fields_required: [
'display_id',
'subject',
'status',
'completed_time',
'resolved_time',
'last_updated_time',
'technician'
],
search_criteria: [
{
field: 'last_updated_time',
condition: 'greater_than',
value: windowStart.toString()
}
]
}
};
const found = data.requests;
const response = await axios.get('https://ithelp.mainecc.edu/api/v3/requests', {
params: { input_data: JSON.stringify(listInfo) },
headers: {
Authorization: `Zoho-oauthtoken ${accessToken}`,
Accept: 'application/vnd.manageengine.sdp.v3+json'
}
});
const found = response.data?.requests || [];
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
@ -173,152 +90,15 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
state.lastCheck = Date.now();
state.lastError = null;
} catch (err) {
rethrowIfSignedOut(err);
state.lastError = describeError(err);
const detail = err.response?.data;
state.lastError = detail?.response_status?.messages?.[0]?.message
|| (typeof detail === 'string' ? detail : null)
|| err.message;
state.lastCheck = Date.now();
}
}
// 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) {
rethrowIfSignedOut(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) {
rethrowIfSignedOut(err);
unassigned.error = describeError(err);
unassigned.updatedAt = Date.now();
console.error('Error: ', detail || err.message);
}
}
module.exports = {
fetchClosedTickets,
fetchWorkload,
fetchUnassigned
fetchClosedTickets
};

View File

@ -1,107 +0,0 @@
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
};