Document hosted per-user setup and plan per-browser settings
README covers the Server-based Zoho client, the new .env settings, deployment (image, compose, Nginx Proxy Manager), signing in and the security model. NOTES records the Zoho OAuth gotchas and the decisions behind the rebuild. ROADMAP describes the current architecture and the planned per-browser settings panel. CLAUDE.md and SESSION.md are brought up to date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c2cf725819
commit
f620cd96d4
30
CLAUDE.md
30
CLAUDE.md
@ -4,9 +4,9 @@ Context for Claude Code sessions in this repo. Keep this short. Longer notes liv
|
|||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
A help desk dashboard for ManageEngine ServiceDesk Plus Cloud (`ithelp.mainecc.edu`). It currently runs as a single Node/Express server. The central region (KVCC, EMCC, CMCC, MCCS) is configured through `.env`.
|
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:** multi-region, multi-user, customisable. See `docs/ROADMAP.md`.
|
- **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.
|
- **Decisions, API gotchas and known issues:** `docs/NOTES.md`. Read it before touching any SDP query.
|
||||||
- **Latest session summary and handoff:** `SESSION.md`.
|
- **Latest session summary and handoff:** `SESSION.md`.
|
||||||
|
|
||||||
@ -17,23 +17,27 @@ npm install
|
|||||||
npm run app # node src/server.js from the repo root; .env lives in the repo root
|
npm run app # node src/server.js from the repo root; .env lives in the repo root
|
||||||
```
|
```
|
||||||
|
|
||||||
The app is served at <http://localhost:3000>. Sign in with the Connect button, which uses Zoho's device-code flow.
|
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
|
## Layout
|
||||||
|
|
||||||
- `src/server.js`: reads `.env` and starts everything.
|
- `src/server.js`: reads `.env`, holds the region `settings` and `timing`, and starts Express.
|
||||||
- `src/auth.js`: Zoho device-code sign-in.
|
- `src/auth.js`: Zoho web OAuth (authorization URL, code exchange, renewal, revoke, user info).
|
||||||
- `src/boardService.js`: token, state and refresh timers.
|
- `src/session.js`: the encrypted session cookie and the `requireSession` middleware, which renews the access token when it's due.
|
||||||
- `src/tickets.js`: all SDP queries (closed feed, status table, closed today, unassigned).
|
- `src/routes.js`: `/auth/*` (login, callback, logout) and `/api/*` (session, closed, workload, weather; all need a sign-in).
|
||||||
- `src/weather.js`: OpenWeatherMap, cached for 10 minutes.
|
- `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/routes.js`: the `/api/*` routes.
|
- `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).
|
- `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
|
## Working agreements
|
||||||
|
|
||||||
- Never print or commit `.env` values (Zoho client secret, OpenWeatherMap key). Redact them when inspecting.
|
- Never print or commit `.env` values (Zoho client secret, `SESSION_SECRET`, OpenWeatherMap key). Redact them when inspecting.
|
||||||
- There are no automated tests yet. Verify server logic by stubbing `axios.get`. Resolve axios from the project with `require.resolve('axios', { paths: [dir] })` so the stub hits the same instance. Verify the page by evaluating its `<script>` with stubbed `document` and `fetch`, or by taking a screenshot with headless Edge.
|
- 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.
|
||||||
- The sandbox can't start a second server instance; it can't bind ports. Screenshots of the live app use the user's running server on port 3000.
|
- 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.
|
- 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.
|
- 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.
|
- Don't commit unless asked. Commit messages end with the Co-Authored-By line for Claude.
|
||||||
|
|||||||
93
README.md
93
README.md
@ -1,6 +1,8 @@
|
|||||||
# sdp-request-watcher
|
# sdp-request-watcher
|
||||||
|
|
||||||
A local Node.js dashboard for ManageEngine ServiceDesk Plus (SDP) Cloud. It shows each technician's open tickets by status, plays a sound when a ticket is closed, tracks the unassigned queue, and shows local weather.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@ -8,7 +10,14 @@ A local Node.js dashboard for ManageEngine ServiceDesk Plus (SDP) Cloud. It show
|
|||||||
|
|
||||||
### 1. Zoho API client
|
### 1. Zoho API client
|
||||||
|
|
||||||
Create a **Non-Browser Application** at <https://api-console.zoho.com/add>. That gives you the client ID and secret. The app only asks for the `SDPOnDemand.requests.READ` scope, so it can read requests but never change them.
|
Create a **Server-based Application** at <https://api-console.zoho.com/add>:
|
||||||
|
|
||||||
|
- **Homepage URL:** the dashboard's address, e.g. `https://dashboard.example.com`.
|
||||||
|
- **Authorized Redirect URI:** that address plus `/auth/callback`, e.g. `https://dashboard.example.com/auth/callback`. It must match `PUBLIC_URL` exactly. To also run it locally, add `http://localhost:3000/auth/callback`, or use a second client for local work.
|
||||||
|
|
||||||
|
That gives you the client ID and secret. The app asks each user for two scopes: `SDPOnDemand.requests.READ`, so it can read requests but never change them, and `AaaServer.profile.READ`, only to show who is signed in.
|
||||||
|
|
||||||
|
The old **Non-Browser Application** client (device-code sign-in) is no longer used.
|
||||||
|
|
||||||
### 2. `.env` file
|
### 2. `.env` file
|
||||||
|
|
||||||
@ -16,16 +25,19 @@ Copy `src/env.example` to a file named `.env` and fill it in. dotenv reads `.env
|
|||||||
|
|
||||||
| Setting | Required | What it does |
|
| Setting | Required | What it does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `CLIENT_ID`, `CLIENT_SECRET` | Yes | The Zoho Non-Browser Application credentials. |
|
| `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. |
|
| `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`. |
|
| `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. |
|
| `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. |
|
| `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`. |
|
| `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. |
|
| `WORKLOAD_MINUTES` | No | How often the status table and unassigned count refresh, in minutes. Defaults to 5. |
|
||||||
| `ADMIN_KEY` | No | Key needed for **Connect** and **Refresh now**. Enter it with the page's **Admin** button; it's remembered in that browser. Without a key, only browsers on the server's own machine can use those buttons. Set one before hosting. |
|
|
||||||
| `TOKEN_FILE` | No | Where the Zoho sign-in is saved. Defaults to `data/token.json`, which git ignores. |
|
|
||||||
| `OWM_API_KEY`, `OWM_ZIP_CODE` | No | OpenWeatherMap key and zip code for the weather card. Leave either one empty to hide the card. A free key from <https://openweathermap.org/api> is enough. New keys can take a couple of hours to start working. |
|
| `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. |
|
||||||
|
|
||||||
Spaces around commas are fine in every list.
|
Spaces around commas are fine in every list.
|
||||||
|
|
||||||
@ -39,32 +51,57 @@ UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved
|
|||||||
|
|
||||||
These match the "Central Region Unassigned" custom view in ManageEngine. The Unassigned requests count was checked against that view and matched.
|
These match the "Central Region Unassigned" custom view in ManageEngine. The Unassigned requests count was checked against that view and matched.
|
||||||
|
|
||||||
### 3. Run it
|
### 3. Run it locally
|
||||||
|
|
||||||
```text
|
```text
|
||||||
npm install
|
npm install
|
||||||
npm run app
|
npm run app
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run app` runs `node src/server.js` from the project root. Then open <http://localhost:3000>. Set `PORT` in `.env` to use a different port.
|
`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.
|
||||||
|
|
||||||
## Connecting to Zoho
|
## Deployment
|
||||||
|
|
||||||
An admin connects once. The sign-in is saved to `data/token.json` and renewed automatically every hour, so the board keeps running and picks up again after a restart. You only connect again if Zoho revokes the sign-in; the page then shows the Connect button again. Treat `data/token.json` like a password.
|
### Image
|
||||||
|
|
||||||
1. Click **Connect**. If `ADMIN_KEY` is set, click **Admin** and enter it first.
|
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
|
||||||
|
```
|
||||||
|
|
||||||
2. Once Zoho sends back a device code, the code and an **Open authorization page** button appear. The Zoho page opens in a new tab.
|
### Server
|
||||||
|
|
||||||

|
On the VPS, put `docker-compose.yml` and a filled-in `.env` in the same folder, then:
|
||||||
|
|
||||||
3. Check that the code matches, tick the box and click **Accept**, then close that tab.
|
```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.
|
||||||
|
|
||||||
4. Return to the dashboard tab. Zoho can't redirect back to a local app, but the dashboard checks the server every few seconds and goes live by itself once you approve.
|
The 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.
|
||||||
|
|
||||||
|
## 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
|
## The dashboard
|
||||||
|
|
||||||
@ -74,7 +111,9 @@ An admin connects once. The sign-in is saved to `data/token.json` and renewed au
|
|||||||
- **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.
|
- **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.
|
- **Refresh now** reruns every check immediately.
|
||||||
|
|
||||||
If a query fails, a red error box shows what SDP returned, and the other sections keep working. If the table ever shows **partial**, a query matched more tickets than it can page through (2,000). The server console then logs the details, including SDP's total count. A very large total usually means SDP ignored the search criteria.
|
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
|
### Unassigned verification list
|
||||||
|
|
||||||
@ -84,32 +123,30 @@ If a query fails, a red error box shows what SDP returned, and the other section
|
|||||||
|
|
||||||
| File | Role |
|
| File | Role |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `src/server.js` | Reads `.env`, then starts Express and the services. |
|
| `src/server.js` | Reads `.env`, then starts Express. |
|
||||||
| `src/auth.js` | Zoho device-code sign-in. |
|
| `src/auth.js` | Zoho web sign-in: the authorization URL, code exchange, token renewal and revoke. |
|
||||||
| `src/boardService.js` | Holds the token and state, renews the token, and runs the refresh timers. |
|
| `src/session.js` | The encrypted session cookie, and renewing the access token when it's due. |
|
||||||
| `src/tokenStore.js` | Saves the Zoho sign-in to disk so it survives restarts. |
|
| `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/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. |
|
||||||
| `src/routes.js` | `/api/state`, `/api/connect`, `/api/check` and `/api/weather`. |
|
| `src/public/index.html` | The dashboard page. It runs the checks and keeps the closed feed. |
|
||||||
| `src/public/index.html` | The dashboard page. |
|
| `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.
|
Every SDP query is limited to the configured sites, groups and technicians. Results are also re-checked locally, so a ticket from outside the region is never counted even if the API returns one.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Node.js 18 or newer. Dependencies are `axios`, `dotenv` and `express`, all installed by `npm install`.
|
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
|
## Credits
|
||||||
|
|
||||||
- **Skylar Grant**: project owner and author.
|
- **Skylar Grant**: project owner and author.
|
||||||
- **Isaac Godin**: original page design.
|
- **Isaac Godin**: original page design.
|
||||||
- **Claude** (Anthropic, via Claude Code): dashboard, region scoping, unassigned counter, weather card and SDP query fixes, built with Skylar.
|
- **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)
|
- [Get List Request](https://www.manageengine.com/products/service-desk/sdpod-v3-api/requests/request.html#get-list-request)
|
||||||
- [Input Data](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/input-data.html)
|
- [Input Data](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/input-data.html)
|
||||||
- [Search Criteria](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/search-criteria.html)
|
- [Search Criteria](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/search-criteria.html)
|
||||||
|
|||||||
50
SESSION.md
50
SESSION.md
@ -1,12 +1,29 @@
|
|||||||
# Session summary: 2026-10-15
|
# Session summary: 2026-10-15
|
||||||
|
|
||||||
A Claude Code session that turned the closed-ticket board into a dashboard. Background is in `CLAUDE.md`, `docs/ROADMAP.md` and `docs/NOTES.md`.
|
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
|
## Where things stand
|
||||||
|
|
||||||
The dashboard is running and working against live SDP data for the central region. All the work below is in the working tree. The last few files (`CLAUDE.md`, `docs/`, `SESSION.md`) may still be uncommitted.
|
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`.
|
||||||
|
|
||||||
## What was built, in order
|
All of this is uncommitted on branch `v1.1.0`. **None of it has been run against live Zoho or built with Docker yet.** It was verified with stubbed Zoho and SDP calls only (see "Verified" below).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **Not verified:** a real Zoho sign-in, the `user/info` response shape, the Docker build, the Gitea workflow, NPM.
|
||||||
|
|
||||||
|
## 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.
|
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.
|
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.
|
||||||
@ -25,32 +42,27 @@ The dashboard is running and working against live SDP data for the central regio
|
|||||||
11. **License:** MIT (LICENSE copyright Skylar Grant; package.json now says MIT, not ISC).
|
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`.
|
12. **Repo structure for working with Claude:** `CLAUDE.md`, `docs/ROADMAP.md`, `docs/NOTES.md`.
|
||||||
|
|
||||||
## Settings added to `.env` this session
|
## Settings in `.env`
|
||||||
|
|
||||||
`SITE_NAMES`, `GROUP_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`, `WORKLOAD_MINUTES` (optional), `OWM_API_KEY`, `OWM_ZIP_CODE`. All are documented in `src/env.example` and the README.
|
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
|
## Known issues
|
||||||
|
|
||||||
- On a typical screen the status table scrolls sideways ("Need Info" gets cut off), because the page is capped at 1,320px.
|
- On a typical screen the status table scrolls sideways ("Need Info" gets cut off), because the page is capped at 1,320px.
|
||||||
- Anyone who can reach the URL can still *see* the data (Connect and Refresh are admin-only now). That's for the auth rebuild to solve.
|
- `images/1.png` and `images/2.png` show the old Connect screens and are no longer used in the README.
|
||||||
|
- The README's reference links for Zoho's device flow were removed; links for the server-based (web) flow haven't been added.
|
||||||
## Phase 1 done (later the same day)
|
- Each open dashboard polls SDP with its own token. That's fine for a few viewers; watch SDP rate limits if wallboards multiply.
|
||||||
|
|
||||||
- `auth.js` keeps the `refresh_token` and adds `refreshAccessToken()`. `boardService.js` renews the access token 5 minutes before it expires (once, even when checks overlap), and `resume()` restarts from `data/token.json` at boot (`src/tokenStore.js`; `TOKEN_FILE` to move it). A revoked sign-in drops back to the Connect screen.
|
|
||||||
- `/api/connect` and `/api/check` are admin-only: `ADMIN_KEY` via the page's Admin button, or localhost only when no key is set. `/api/state` hides the device code from non-admins and reports `isAdmin`.
|
|
||||||
- Verified with stubbed axios and a stubbed page DOM, not yet against live Zoho. **To check live:** connect, restart the server, and confirm it comes back live without Connect. Then wait more than an hour and confirm "Access token renewed" appears in the log.
|
|
||||||
|
|
||||||
## Plan change
|
|
||||||
|
|
||||||
Phases 2 to 5 are dropped. The whole auth process is being rebuilt to run on a server instead of locally, and the roadmap will be redrawn around it. Don't start any of the old phases. Phase 1 (token renewal, saved sign-in, `ADMIN_KEY`) still works today, but the rebuild may replace it. See `docs/ROADMAP.md`.
|
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|
||||||
1. **Check phase 1 against live Zoho** (steps above), then commit it so the board keeps running in the meantime.
|
1. **Zoho client:** create the Server-based Application with the production redirect URI (and localhost for development). Put its ID and secret, `PUBLIC_URL` and a new `SESSION_SECRET` into the VPS `.env`.
|
||||||
2. **Plan the server-based auth rebuild** and rewrite the phases in `docs/ROADMAP.md` around it.
|
2. **Gitea and Docker Hub:** add the `DHUB_UNAME` and `DHUB_PASS` secrets, make sure the runner has the `self-hosted` label and Docker, then push a `v*-prod*` tag.
|
||||||
|
3. **VPS:** check NPM's network name, then `docker compose up -d`. Set up the NPM proxy host (forward to `sdp-dashboard:3000`, SSL, Force SSL) and the MCCS access list.
|
||||||
|
4. **Check live:** sign in; go more than an hour without being asked to sign in again; sign out; confirm an outside IP is blocked.
|
||||||
|
5. **Then:** per-browser settings, as designed in `docs/ROADMAP.md` (not started).
|
||||||
|
|
||||||
## To pick up
|
## To pick up
|
||||||
|
|
||||||
Start a Claude Code session in this repo and say something like:
|
Start a Claude Code session in this repo and say something like:
|
||||||
|
|
||||||
*"Read SESSION.md and docs/ROADMAP.md. The old phases 2 to 5 are dropped, and I'm rebuilding auth to run on a server instead of locally. Help me plan that rebuild and redraw the roadmap around it."*
|
*"Read SESSION.md and docs/ROADMAP.md. The per-user Zoho sign-in and Docker setup are built but not yet tested live. Help me get it deployed and checked, then we'll plan the per-browser settings panel."*
|
||||||
|
|||||||
@ -10,21 +10,29 @@ Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-s
|
|||||||
- **"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.
|
- **"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`.
|
- **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".
|
- **Group names:** the ManageEngine UI shows groups as "Customer Support « KVCC", but the API name is plain "Customer Support".
|
||||||
- **Sign-in scope:** `SDPOnDemand.requests.READ` is enough for everything so far.
|
- **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.
|
||||||
- **Token lifetime:** Zoho access tokens last about an hour. The device flow only returns a `refresh_token` because the device-code request asks for `access_type: offline`. Renew it with `POST accounts.zoho.com/oauth/v2/token`, `grant_type=refresh_token`.
|
|
||||||
- **Zoho errors come back as HTTP 200:** a rejected refresh token returns `{ "error": "invalid_code" }` with status 200, so check for `access_token` rather than relying on axios throwing. The device-token poll works the same way (`authorization_pending`).
|
## 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.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
- **2026-10-15:** **MIT license.** Copyright Skylar Grant.
|
- **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:** **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:** **Unassigned counter** uses its own status exclusions (`UNASSIGNED_EXCLUDED_STATUSES`) to match the ManageEngine view. The debug list is kept but hidden (`SHOW_UNASSIGNED_LIST` in `index.html`).
|
||||||
- **2026-10-15:** **Multi-region approach:** one service token plus region config, not per-user Zoho tokens. A user's own token could see all 124k tickets, so it wouldn't separate regions, and per-user polling multiplies API traffic. See `ROADMAP.md`.
|
- **2026-10-15:** **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:** **Admin key until real sign-in.** `ADMIN_KEY` in `.env`, sent as `X-Admin-Key`, guards `/api/connect` and `/api/check`. With no key set, only loopback requests are admin. Loopback isn't trusted once a key is set, because behind a reverse proxy every request is loopback. Non-admins never see the device code; otherwise anyone could approve the board with their own Zoho account.
|
- **2026-10-15:** **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:** **Token saved to a JSON file** (`data/token.json`, mode 600, written atomically), not SQLite yet. Move it into the database in phase 2 if one exists by then. Renewal failures with a 400 or a Zoho `error` drop the token and delete the file; network errors keep it and retry on the next check.
|
- **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
|
## 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.
|
- The status table scrolls sideways once there are more statuses than fit, because the page is capped at 1,320px. Consider widening the page or narrowing the sidebar.
|
||||||
- All statuses not in `STATUS_NAMES` count as open, including "Need Information" and "On Hold". This is intentional for now; revisit when regions get their own config.
|
- 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.
|
||||||
|
- `images/1.png` and `images/2.png` show the old device-code Connect screens. The README no longer uses them. Retake them for the sign-in page or delete them.
|
||||||
|
|||||||
@ -2,26 +2,33 @@
|
|||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
A hosted dashboard where staff in each MCCS region sign in and see their own region's queue. It should be customisable per user, and easy to extend with new panels.
|
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.
|
||||||
|
|
||||||
## Target architecture
|
## Current architecture
|
||||||
|
|
||||||
- **Identity:** users sign in (Zoho web OAuth, or Microsoft if MCCS staff use it) only to identify themselves.
|
- **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.
|
||||||
- **Data:** one service connection, signed in once by an admin, with its refresh token kept and auto-renewed. Its SDP account must be able to see every region.
|
- **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.
|
||||||
- **Regions:** profiles stored in SQLite (sites, groups, technicians, statuses). Checks run once per region and the results are cached, so API traffic grows with regions, not viewers.
|
- **Sessions:** the Zoho tokens live in an encrypted, `HttpOnly` cookie. The server stores nothing, so restarts and upgrades don't sign anyone out.
|
||||||
- **Access:** users are assigned to regions and the server enforces it. An admin role manages regions and assignments.
|
- **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.
|
||||||
- **Extensibility:** each panel is a module with server code to fetch its data and page code to draw it. The weather card is the first example.
|
- **Region:** central region only, from `.env` (`SITE_NAMES`, `GROUP_NAMES`, `TECHNICIAN_NAMES` and the status lists).
|
||||||
|
|
||||||
## Phases
|
## Next
|
||||||
|
|
||||||
Phases 2 to 5 (regions as configuration, user sign-in, admin pages, deployment) were dropped on 2026-10-15. Don't start them. The whole auth process is being rebuilt to run on a server instead of locally, and the plan after phase 1 will be redrawn around that. Until then, the Identity, Data and Access bullets in the target architecture above may be out of date. The open questions below still need answering.
|
- [ ] **Check the rebuild live:** create the Zoho Server-based client, deploy with a `v*-prod*` tag, sign in, and confirm the token renews after an hour (no sign-in prompt), that Sign out revokes it, and that NPM's access list blocks an outside address.
|
||||||
- [ ] **Later:** per-user preferences (default region, hidden columns, sound) and panel modules.
|
- [ ] **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
|
## Open questions
|
||||||
|
|
||||||
- Are regions a **security boundary** (staff can't see other regions) or a **convenience** (default view with a switcher)?
|
- 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.
|
||||||
- Do MCCS staff sign in with Microsoft 365 or Entra ID? That decides between Zoho and Microsoft sign-in.
|
- 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.
|
||||||
- Does SDP itself restrict technicians to certain sites? If it does, per-user tokens become an option.
|
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
@ -30,5 +37,5 @@ Phases 2 to 5 (regions as configuration, user sign-in, admin pages, deployment)
|
|||||||
- Unassigned counter. Verified to match the ManageEngine "Central Region Unassigned" view on 2026-10-15.
|
- Unassigned counter. Verified to match the ManageEngine "Central Region Unassigned" view on 2026-10-15.
|
||||||
- Weather card (OpenWeatherMap).
|
- Weather card (OpenWeatherMap).
|
||||||
- Fixed SDP paging and the date filter (see `NOTES.md`).
|
- Fixed SDP paging and the date filter (see `NOTES.md`).
|
||||||
- Phase 1: the Zoho refresh token is kept, the access token is renewed 5 minutes before it expires, and the sign-in is saved to `data/token.json` (`TOKEN_FILE`) so it survives restarts.
|
- 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.
|
||||||
- Phase 1b: Connect and Refresh are admin-only (`ADMIN_KEY`, or localhost when no key is set), and only admins see the device code. Phase 3 replaces the key with real sign-in.
|
- Docker image `v0idf1sh/sdp-dashboard`, an example `docker-compose.yml`, and a Gitea Actions build on `v*-prod*` tags.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user