Add data volume and document per-user settings
All checks were successful
Build and push image / build (push) Successful in 13s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Skylar Grant 2026-10-15 21:04:22 -04:00
parent 53c519ed62
commit 817fa47edd
9 changed files with 64 additions and 24 deletions

View File

@ -6,6 +6,7 @@ npm-debug.log*
.env
.env.*
docs
data
images
*.md
Dockerfile

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
# saved settings and name suggestions from local runs
data/
# Logs
logs
*.log

View File

@ -4,9 +4,9 @@ Context for Claude Code sessions in this repo. Keep this short. Longer notes liv
## 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`.
A help desk dashboard for ManageEngine ServiceDesk Plus Cloud (`ithelp.mainecc.edu`). It's a 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. `.env` sets the defaults (central region: KVCC, EMCC, CMCC, MCCS); each user can override region, timing, weather and hidden panels in Settings, saved on the server by Zoho email in `DATA_DIR` (a Docker volume).
- **Goal and next steps:** per-browser settings and panel modules. See `docs/ROADMAP.md`.
- **Goal and next steps:** per-user settings (built, needs live checks) 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`.
@ -26,10 +26,13 @@ Releases: push a `v*-prod*` tag. `.gitea/workflows/docker.yml` builds and pushes
- `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/routes.js`: `/auth/*` (login, callback, logout) and `/api/*` (session, settings, names, closed, workload, weather; all need a sign-in).
- `src/settings.js`: per-user settings: validation, `LIMITS`, merging over the `.env` defaults. `src/store.js` is the JSON file store under it.
- `src/names.js`: shared cache of site/group/technician names for the settings suggestions.
- `src/places.js`: the weather locations people can pick.
- `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.
- `src/weather.js`: OpenWeatherMap, cached for 10 minutes per zip 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, keeps the closed feed, and has the settings dialog.
- `Dockerfile`, `docker-compose.yml` (example for the VPS), `.gitea/workflows/docker.yml`.
## Working agreements

View File

@ -9,7 +9,10 @@ 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
# sessions live in each user's browser; the only state is saved settings and name
# suggestions in /app/data, which compose mounts as a volume. Made here so node owns it.
RUN mkdir -p /app/data && chown node:node /app/data
ENV DATA_DIR=/app/data
USER node
EXPOSE 3000

View File

@ -19,7 +19,7 @@ The old **Non-Browser Application** client (device-code sign-in) is no longer us
### 2. `.env` file
Copy `src/env.example` to a file named `.env` and fill it in. dotenv reads `.env` from the folder you start the server in, so keep `.env` in the project root and start the server from there (see step 3).
Copy `src/env.example` to a file named `.env` and fill it in. The region, timing and weather settings are the defaults everyone starts with; each person can change their own in **Settings** (see [Settings](#settings)). dotenv reads `.env` from the folder you start the server in, so keep `.env` in the project root and start the server from there (see step 3).
| Setting | Required | What it does |
| --- | --- | --- |
@ -33,7 +33,8 @@ Copy `src/env.example` to a file named `.env` and fill it in. dotenv reads `.env
| `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. |
| `OWM_API_KEY`, `OWM_ZIP_CODE` | No | OpenWeatherMap key and the default zip code for the weather card. Without a key the card is hidden. People pick other locations from the list in `src/places.js`. A free key from <https://openweathermap.org/api> is enough. New keys can take a couple of hours to start working. |
| `DATA_DIR` | No | Where saved settings (`settings.json`) and name suggestions (`names.json`) are kept. Defaults to `data/` in the project root; the Docker image uses `/app/data`. |
| `PORT` | No | Port the server listens on. Defaults to 3000. Leave it alone in Docker. |
| `TRUST_PROXY` | No | Which proxies to believe for the client's IP address in the logs. Defaults to private and loopback addresses, which covers NPM on the same Docker host. |
@ -87,7 +88,7 @@ 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.
Sign-ins live in each browser's cookie, so restarting or upgrading the container doesn't sign anyone out. The only state is people's saved settings and the name suggestions, in the `sdp-data` volume mounted at `/app/data`. Keep that volume when upgrading; losing it only resets everyone to the `.env` defaults.
The compose file publishes no ports. Docker's port publishing bypasses the host firewall, so `3000:3000` would let anyone reach the app directly and skip NPM's access list. Instead the container joins NPM's Docker network (`npm_default` by default; set `NPM_NETWORK` if yours is named differently, and check with `docker network ls`).
@ -100,7 +101,7 @@ The compose file publishes no ports. Docker's port publishing bypasses the host
## 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`.
Opening the dashboard shows a **Sign in with Zoho** button. It goes to Zoho's consent page, and Zoho sends you back signed in. Use the Zoho account you use for ServiceDesk Plus. The dashboard shows what that account can see, limited to the region in that person's settings.
The sign-in is kept in an encrypted cookie in that browser for `SESSION_DAYS` (7 by default), and the hourly Zoho access token is renewed automatically. **Sign out** clears the cookie and revokes the token at Zoho. If Zoho or SDP stops accepting the sign-in, the page goes back to the sign-in button.
@ -110,6 +111,7 @@ The sign-in is kept in an encrypted cookie in that browser for `SESSION_DAYS` (7
- **Per-user sign-in:** every request needs the user's own Zoho sign-in. There is no shared token, so a reachable page with no sign-in shows nothing.
- **Tokens:** the Zoho tokens live only in the user's cookie, encrypted and authenticated with `SESSION_SECRET` (AES-256-GCM). The cookie is `HttpOnly`, so page scripts can't read it, and `SameSite=Lax`. The server doesn't store tokens or sessions.
- **Read-only:** the only SDP scope is `SDPOnDemand.requests.READ`.
- **Settings:** saved on the server, keyed by the email Zoho returns at sign-in. They hold names and numbers only, never tokens. The server checks every value (plain names, list lengths, refresh intervals, weather locations from a fixed list). Changing the region can't show anyone more than SDP already lets them see, because every query uses their own token.
## The dashboard
@ -119,7 +121,18 @@ The sign-in is kept in an encrypted cookie in that browser for `SESSION_DAYS` (7
- **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.
## Settings
**⚙ Settings** in the header, and a setup prompt on someone's first visit, let each person choose:
- **Sites, groups and technicians** to watch. Empty means everyone. Suggestions come from names seen on recent tickets anywhere in SDP (refreshed at most hourly and shared by everyone). Any name can be typed, but it must match SDP's spelling; a name that differs only in case is saved with SDP's spelling.
- **Timing:** how often the status table recounts (2 to 60 minutes) and how long closed tickets stay in the feed (1 to 24 hours). The closed check itself always runs every minute.
- **Weather location**, from the list of campuses in `src/places.js`.
- **What's shown:** the stat tiles, weather card and closed feed can be hidden (the ding still plays with the feed hidden), and so can individual status columns.
Settings are saved to the person's Zoho account on the server, so they follow them to any browser. Only the values that differ from `.env` are stored, so someone who never changed the region picks up later changes to `.env`. **Reset to defaults** goes back to `.env`. The closed and open status rules aren't per person; they stay in `.env`.
The browser runs the checks, using the signed-in user's token, so every open dashboard makes its own SDP calls. That's one small query a minute and a recount at the person's chosen interval (`WORKLOAD_MINUTES` by default). On first load it fetches the feed's full length (12 hours by default) of closes.
If a query fails, a red error box shows what SDP returned, and the other sections keep working. If the table ever shows **partial**, a query matched more tickets than it can page through (2,000). The server log (`docker compose logs`) then has the details, including SDP's total count. A very large total usually means SDP ignored the search criteria.
@ -134,9 +147,13 @@ If a query fails, a red error box shows what SDP returned, and the other section
| `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/routes.js` | `/auth/login`, `/auth/callback` and `/auth/logout`, plus `/api/session`, `/api/settings`, `/api/names`, `/api/closed`, `/api/workload` and `/api/weather`, which need a sign-in. |
| `src/settings.js` | Each user's saved settings: validation, limits, and merging over the `.env` defaults. |
| `src/names.js` | The shared cache of site, group and technician names for the settings suggestions. |
| `src/store.js` | The small JSON file store both of those use. |
| `src/places.js` | The weather locations people can pick from. |
| `src/tickets.js` | SDP API queries for the closed feed, the status table, closed today and unassigned requests. |
| `src/weather.js` | OpenWeatherMap current weather and forecast, cached for 10 minutes. |
| `src/weather.js` | OpenWeatherMap current weather and forecast, cached for 10 minutes per location. |
| `src/public/index.html` | The dashboard page. It runs the checks and keeps the closed feed. |
| `Dockerfile`, `docker-compose.yml` | The image, and an example of how to run it. |
| `.gitea/workflows/docker.yml` | Builds and pushes the image on `v*-prod*` tags. |

View File

@ -15,9 +15,15 @@ services:
# shared network instead, using the container name as the forward hostname.
expose:
- "3000"
# each user's saved settings and the shared name suggestions
volumes:
- sdp-data:/app/data
networks:
- proxy
volumes:
sdp-data:
networks:
proxy:
# the network Nginx Proxy Manager's container is on; check with `docker network ls`

View File

@ -34,11 +34,18 @@ Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-s
- **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:** **Per-user settings are stored on the server, keyed by Zoho email** (lowercased), in `DATA_DIR/settings.json`. Chosen over the planned `localStorage` so settings follow people between browsers and no export/import is needed. This gives the container state, so compose mounts the `sdp-data` volume at `/app/data`. Only values that differ from `.env` are stored, so `.env` changes still reach people who never changed that field. A plain JSON file, rewritten via temp file and rename, is enough for a small team with one process; move to SQLite if that stops being true.
- **2026-10-15:** **The server reads settings itself** rather than the browser sending them with each call, as first planned. The browser can only change them through `PUT /api/settings`, which validates everything (`LIMITS` in `settings.js`). That's for protecting SDP and the server, not access control: queries use the user's own token, and everyone at MCCS can see every site in SDP anyway.
- **2026-10-15:** **Name suggestions without a new scope.** `/api/names` runs one unscoped query (last 30 days, up to 500 tickets, names only) with the caller's token, at most hourly for everyone, and merges into `DATA_DIR/names.json` so the list grows over time. Typed names are still accepted; a name matching a suggestion except for case is saved with SDP's spelling, because the technician filters compare exactly. Proper pick lists from SDP's setup/users APIs would need another scope and a re-sign-in for everyone.
- **2026-10-15:** **Weather locations are a fixed list** (`src/places.js`), not free zip codes, so a browser can't make the server look up arbitrary places and the per-zip cache stays small. The `.env` zip is added to the list if it's missing.
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
- **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.
- All statuses not in `STATUS_NAMES` count as open, including "Need Information" and "On Hold". This is intentional; people can hide those columns in Settings.
- If Zoho doesn't return an email at sign-in (the profile call failed), that session can't save settings and uses the defaults; signing in again fixes it.
- No screenshots: `images/` was removed on 2026-10-15 and new ones are due.

View File

@ -8,20 +8,14 @@ A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zo
- **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).
- **Sessions:** the Zoho tokens live in an encrypted, `HttpOnly` cookie. The server stores no sessions, so restarts and upgrades don't sign anyone out. Its only state is saved settings and name suggestions.
- **Polling:** the browser runs the checks (closed feed every minute, status table at the user's interval) and keeps the closed feed. The server answers each call with the caller's token and their saved settings. Weather (per location) and name suggestions are shared.
- **Region and settings:** `.env` sets the defaults (`SITE_NAMES`, `GROUP_NAMES`, `TECHNICIAN_NAMES`, `WORKLOAD_MINUTES`, `OWM_ZIP_CODE`). Each user can override sites, groups, technicians, timing, weather location and hidden panels/columns in Settings; these are saved on the server by Zoho email in `DATA_DIR` (a Docker volume). The status lists stay in `.env` for everyone.
## 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.
- [ ] **Per-user settings: finish live checks.** Built on 2026-10-15 (branch `v2.1.0`), tested with stubs only. Check against real SDP: the name suggestions query, the setup prompt on first sign-in, and the `sdp-data` volume surviving `docker compose pull && up -d`. Fill in campus satellites in `src/places.js`.
- [ ] **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`).
@ -40,3 +34,4 @@ A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zo
- 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.
- Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers.

View File

@ -7,6 +7,8 @@ PUBLIC_URL=https://dashboard.example.com
SESSION_SECRET=
# optional: days before a sign-in expires and Zoho is asked again (default 7)
SESSION_DAYS=7
# The region below is the default each user starts with; anyone can change their own in Settings.
# technicians in the status table (leave empty for everyone, plus an Unassigned row)
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)
@ -17,6 +19,9 @@ GROUP_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=
# default weather location; people can pick another from the list in src/places.js
OWM_ZIP_CODE=04937
# optional: how often the technician/status table is recounted, in minutes (default 5)
WORKLOAD_MINUTES=5
# optional: where saved settings and name suggestions are kept (default ./data; /app/data in Docker)
# DATA_DIR=