Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e79a24efd | |||
| 1e3f8dc1ad | |||
| c75b5889cf | |||
| 8c7b5fbd26 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
|
data/
|
||||||
pids
|
pids
|
||||||
*.pid
|
*.pid
|
||||||
*.seed
|
*.seed
|
||||||
|
|||||||
40
CLAUDE.md
Normal file
40
CLAUDE.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Context for Claude Code sessions in this repo. Keep this short. Longer notes live in `docs/`.
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
A help desk dashboard for ManageEngine ServiceDesk Plus Cloud (`ithelp.mainecc.edu`). It currently runs as a single Node/Express server. The central region (KVCC, EMCC, CMCC, MCCS) is configured through `.env`.
|
||||||
|
|
||||||
|
- **Goal:** multi-region, multi-user, customisable. See `docs/ROADMAP.md`.
|
||||||
|
- **Decisions, API gotchas and known issues:** `docs/NOTES.md`. Read it before touching any SDP query.
|
||||||
|
- **Latest session summary and handoff:** `SESSION.md`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm install
|
||||||
|
npm run app # node src/server.js from the repo root; .env lives in the repo root
|
||||||
|
```
|
||||||
|
|
||||||
|
The app is served at <http://localhost:3000>. Sign in with the Connect button, which uses Zoho's device-code flow.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `src/server.js`: reads `.env` and starts everything.
|
||||||
|
- `src/auth.js`: Zoho device-code sign-in.
|
||||||
|
- `src/boardService.js`: token, state and refresh timers.
|
||||||
|
- `src/tickets.js`: all SDP queries (closed feed, status table, closed today, unassigned).
|
||||||
|
- `src/weather.js`: OpenWeatherMap, cached for 10 minutes.
|
||||||
|
- `src/routes.js`: the `/api/*` routes.
|
||||||
|
- `src/public/index.html`: the whole front end in one file (inline CSS and JS, no build step).
|
||||||
|
|
||||||
|
## Working agreements
|
||||||
|
|
||||||
|
- Never print or commit `.env` values (Zoho client secret, OpenWeatherMap key). Redact them when inspecting.
|
||||||
|
- There are no automated tests yet. Verify server logic by stubbing `axios.get`. Resolve axios from the project with `require.resolve('axios', { paths: [dir] })` so the stub hits the same instance. Verify the page by evaluating its `<script>` with stubbed `document` and `fetch`, or by taking a screenshot with headless Edge.
|
||||||
|
- The sandbox can't start a second server instance; it can't bind ports. Screenshots of the live app use the user's running server on port 3000.
|
||||||
|
- Match the existing style: 4-space indent and CommonJS on the server, 2-space indent in `index.html`, lowercase `//` comments explaining why rather than what. Some files have CRLF line endings, so use the Edit tool rather than sed for multi-line changes.
|
||||||
|
- Keep the core closed-ticket watcher (feed and ding) working and on by default.
|
||||||
|
- Don't commit unless asked. Commit messages end with the Co-Authored-By line for Claude.
|
||||||
|
- Record new decisions and gotchas in `docs/NOTES.md`, and move roadmap items as they're done.
|
||||||
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 sgrant
|
Copyright (c) 2026 Skylar Grant
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
|||||||
114
README.md
114
README.md
@ -1,44 +1,111 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
Technicians and Statuses are checked in the .env file
|
## Setup
|
||||||
|
|
||||||
I resolved the issue with it only finding closed tickets so it should now find resolved and closed I havent tested with others but will
|
### 1. Zoho API client
|
||||||
|
|
||||||
The technician field can have spaces after the comma however the statuses can not. There can be no spaces before the status or it wont work properly.
|
Create a **Non-Browser Application** at <https://api-console.zoho.com/add>. That gives you the client ID and secret. The app only asks for the `SDPOnDemand.requests.READ` scope, so it can read requests but never change them.
|
||||||
|
|
||||||
|
### 2. `.env` file
|
||||||
|
|
||||||
|
Copy `src/env.example` to a file named `.env` and fill it in. dotenv reads `.env` from the folder you start the server in, so keep `.env` in the project root and start the server from there (see step 3).
|
||||||
|
|
||||||
## Site Setup
|
| Setting | Required | What it does |
|
||||||
Open the site after the server is running, then connect the Zoho account from the board.
|
| --- | --- | --- |
|
||||||
|
| `CLIENT_ID`, `CLIENT_SECRET` | Yes | The Zoho Non-Browser Application credentials. |
|
||||||
|
| `TECHNICIAN_NAMES` | No | Comma-separated technician names, spelled as they appear in SDP. One table row per name. Leave it empty to include every technician. |
|
||||||
|
| `STATUS_NAMES` | No | Statuses that count as "closed" for the ding and the Closed today count. Everything else counts as open. Defaults to `Closed,Resolved,Canceled`. |
|
||||||
|
| `SITE_NAMES` | No | Only include requests from these sites (the region). Leave it empty for all sites. |
|
||||||
|
| `GROUP_NAMES` | No | Only include requests in these support groups, e.g. `Customer Support`. Leave it empty for all groups. |
|
||||||
|
| `UNASSIGNED_EXCLUDED_STATUSES` | No | Statuses the Unassigned requests counter leaves out. Defaults to `STATUS_NAMES`. |
|
||||||
|
| `WORKLOAD_MINUTES` | No | How often the status table and unassigned count refresh, in minutes. Defaults to 5. |
|
||||||
|
| `ADMIN_KEY` | No | Key needed for **Connect** and **Refresh now**. Enter it with the page's **Admin** button; it's remembered in that browser. Without a key, only browsers on the server's own machine can use those buttons. Set one before hosting. |
|
||||||
|
| `TOKEN_FILE` | No | Where the Zoho sign-in is saved. Defaults to `data/token.json`, which git ignores. |
|
||||||
|
| `OWM_API_KEY`, `OWM_ZIP_CODE` | No | OpenWeatherMap key and zip code for the weather card. Leave either one empty to hide the card. A free key from <https://openweathermap.org/api> is enough. New keys can take a couple of hours to start working. |
|
||||||
|
|
||||||
1. Start the server from the `src` folder with node ./server.js
|
Spaces around commas are fine in every list.
|
||||||
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.
|
|
||||||
|
|
||||||
What you should see:
|
The central region settings currently in use:
|
||||||
|
|
||||||

|
```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
|
||||||
|
```
|
||||||
|
|
||||||

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

|
### 3. Run it
|
||||||
|
|
||||||

|
```text
|
||||||
|
npm install
|
||||||
|
npm run app
|
||||||
|
```
|
||||||
|
|
||||||
Once connected, the board will begin checking for tickets automatically and refreshing on its own based on the time set in the server.js file.
|
`npm run app` runs `node src/server.js` from the project root. Then open <http://localhost:3000>. Set `PORT` in `.env` to use a different port.
|
||||||
|
|
||||||
|
## Connecting to Zoho
|
||||||
|
|
||||||
|
An admin connects once. The sign-in is saved to `data/token.json` and renewed automatically every hour, so the board keeps running and picks up again after a restart. You only connect again if Zoho revokes the sign-in; the page then shows the Connect button again. Treat `data/token.json` like a password.
|
||||||
|
|
||||||
|
1. Click **Connect**. If `ADMIN_KEY` is set, click **Admin** and enter it first.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
3. Check that the code matches, tick the box and click **Accept**, then close that tab.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
4. Return to the dashboard tab. Zoho can't redirect back to a local app, but the dashboard checks the server every few seconds and goes live by itself once you approve.
|
||||||
|
|
||||||
|
## The dashboard
|
||||||
|
|
||||||
|
- **Stat tiles**: unassigned requests, total open requests, closed today, closed in the last hour, and the technician with the most open tickets.
|
||||||
|
- **Tickets by technician**: each technician's open tickets by status. **Total open** and **Closed today** come first, so they stay visible on narrow screens while the status columns scroll sideways. Darker cells mean more tickets. A row flashes when that technician closes a ticket.
|
||||||
|
- **Weather**: current conditions and a 5-day forecast for `OWM_ZIP_CODE`, refreshed every 10 minutes.
|
||||||
|
- **Recently closed**: the live closed-ticket feed, checked every minute. A ding plays for each new close. The sound is on by default, and the **Sound on close** toggle mutes it in that browser.
|
||||||
|
- **Refresh now** reruns every check immediately.
|
||||||
|
|
||||||
|
If a query fails, a red error box shows what SDP returned, and the other sections keep working. If the table ever shows **partial**, a query matched more tickets than it can page through (2,000). The server console then logs the details, including SDP's total count. A very large total usually means SDP ignored the search criteria.
|
||||||
|
|
||||||
|
### Unassigned verification list
|
||||||
|
|
||||||
|
`src/public/index.html` still has a hidden table listing every unassigned request by ID and subject. It was used to confirm the count matches the ManageEngine view. To show it again, set `SHOW_UNASSIGNED_LIST = true`.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/server.js` | Reads `.env`, then starts Express and the services. |
|
||||||
|
| `src/auth.js` | Zoho device-code sign-in. |
|
||||||
|
| `src/boardService.js` | Holds the token and state, renews the token, and runs the refresh timers. |
|
||||||
|
| `src/tokenStore.js` | Saves the Zoho sign-in to disk so it survives restarts. |
|
||||||
|
| `src/tickets.js` | SDP API queries for the closed feed, the status table, closed today and unassigned requests. |
|
||||||
|
| `src/weather.js` | OpenWeatherMap current weather and forecast, cached for 10 minutes. |
|
||||||
|
| `src/routes.js` | `/api/state`, `/api/connect`, `/api/check` and `/api/weather`. |
|
||||||
|
| `src/public/index.html` | The dashboard page. |
|
||||||
|
|
||||||
|
Every SDP query is limited to the configured sites, groups and technicians. Results are also re-checked locally, so a ticket from outside the region is never counted even if the API returns one.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
axios
|
|
||||||
dotenv
|
|
||||||
|
|
||||||
|
Node.js 18 or newer. Dependencies are `axios`, `dotenv` and `express`, all installed by `npm install`.
|
||||||
|
|
||||||
## Reference Materials/ Provided by Skylar
|
## Credits
|
||||||
|
|
||||||
|
- **Skylar Grant**: project owner and author.
|
||||||
|
- **Isaac Godin**: original page design.
|
||||||
|
- **Claude** (Anthropic, via Claude Code): dashboard, region scoping, unassigned counter, weather card and SDP query fixes, built with Skylar.
|
||||||
|
|
||||||
|
## Reference materials (provided by Skylar)
|
||||||
|
|
||||||
- [Non-Browser Apps - Overview](https://www.zoho.com/accounts/protocol/oauth/limited-input-devices.html)
|
- [Non-Browser Apps - 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 - Initiation Request](https://www.zoho.com/accounts/protocol/oauth/devices/initiation-request.html)
|
||||||
@ -46,3 +113,8 @@ dotenv
|
|||||||
- [Get List Request](https://www.manageengine.com/products/service-desk/sdpod-v3-api/requests/request.html#get-list-request)
|
- [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)
|
||||||
|
- [OpenWeatherMap Current Weather](https://openweathermap.org/current) and [5 Day Forecast](https://openweathermap.org/forecast5)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
|
|||||||
56
SESSION.md
Normal file
56
SESSION.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# Session summary: 2026-10-15
|
||||||
|
|
||||||
|
A Claude Code session that turned the closed-ticket board into a dashboard. Background is in `CLAUDE.md`, `docs/ROADMAP.md` and `docs/NOTES.md`.
|
||||||
|
|
||||||
|
## Where things stand
|
||||||
|
|
||||||
|
The dashboard is running and working against live SDP data for the central region. All the work below is in the working tree. The last few files (`CLAUDE.md`, `docs/`, `SESSION.md`) may still be uncommitted.
|
||||||
|
|
||||||
|
## What was built, in order
|
||||||
|
|
||||||
|
1. **Dashboard redesign** (`index.html`, `tickets.js`, `boardService.js`): stat tiles; a technician × status table with Total open and Closed today columns; the live closed feed in a side column; a "Sound on close" toggle, on by default; Refresh now. The table refreshes every `WORKLOAD_MINUTES` (default 5). The closed watcher still checks every minute.
|
||||||
|
2. **Central region scoping:** `SITE_NAMES` and `GROUP_NAMES` in `.env` are applied to every SDP query, and each ticket is also re-checked locally.
|
||||||
|
3. **Unassigned requests counter:** `UNASSIGNED_EXCLUDED_STATUSES` mirrors the ManageEngine "Central Region Unassigned" view. **Verified to match the view exactly.** The debug list is kept but hidden (`SHOW_UNASSIGNED_LIST = false` in `index.html`).
|
||||||
|
4. **Weather card** (`src/weather.js`, `/api/weather`): OpenWeatherMap current weather and 5-day forecast with icons, using `OWM_API_KEY` and `OWM_ZIP_CODE`, cached for 10 minutes. Working.
|
||||||
|
5. **Sign-in changes:** the Zoho page opens in a new tab, the button only appears once the device code exists, and the page checks quickly after Connect is clicked.
|
||||||
|
6. **Table layout:** totals moved to the left so they don't get cut off; the auth panel text is wider.
|
||||||
|
7. **SDP query fixes:**
|
||||||
|
- `greater_than` → `greater than`. The wrong spelling made SDP ignore all the criteria and return about 124k tickets.
|
||||||
|
- Paging now dedupes by ID and stops early when the total can't be reached.
|
||||||
|
- The "partial" note now names which query was cut off.
|
||||||
|
- **Verified fixed:** the "partial" note is gone.
|
||||||
|
8. **Small fixes:** `[hidden]` CSS bug (Refresh now showed when disconnected); a misplaced comment in `server.js`.
|
||||||
|
9. **README rewritten**, with new screenshots in `images/`. `1.png` and `2.png` are rendered with sample data; `4.png` is from the live server; `3.png` is unchanged.
|
||||||
|
10. **Credits:** page footer, README, `package.json` contributors, `server.js` header. Isaac Godin keeps the design credit.
|
||||||
|
11. **License:** MIT (LICENSE copyright Skylar Grant; package.json now says MIT, not ISC).
|
||||||
|
12. **Repo structure for working with Claude:** `CLAUDE.md`, `docs/ROADMAP.md`, `docs/NOTES.md`.
|
||||||
|
|
||||||
|
## Settings added to `.env` this session
|
||||||
|
|
||||||
|
`SITE_NAMES`, `GROUP_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`, `WORKLOAD_MINUTES` (optional), `OWM_API_KEY`, `OWM_ZIP_CODE`. All are documented in `src/env.example` and the README.
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
- On a typical screen the status table scrolls sideways ("Need Info" gets cut off), because the page is capped at 1,320px.
|
||||||
|
- Anyone who can reach the URL can still *see* the data (Connect and Refresh are admin-only now). That's for the auth rebuild to solve.
|
||||||
|
|
||||||
|
## Phase 1 done (later the same day)
|
||||||
|
|
||||||
|
- `auth.js` keeps the `refresh_token` and adds `refreshAccessToken()`. `boardService.js` renews the access token 5 minutes before it expires (once, even when checks overlap), and `resume()` restarts from `data/token.json` at boot (`src/tokenStore.js`; `TOKEN_FILE` to move it). A revoked sign-in drops back to the Connect screen.
|
||||||
|
- `/api/connect` and `/api/check` are admin-only: `ADMIN_KEY` via the page's Admin button, or localhost only when no key is set. `/api/state` hides the device code from non-admins and reports `isAdmin`.
|
||||||
|
- Verified with stubbed axios and a stubbed page DOM, not yet against live Zoho. **To check live:** connect, restart the server, and confirm it comes back live without Connect. Then wait more than an hour and confirm "Access token renewed" appears in the log.
|
||||||
|
|
||||||
|
## Plan change
|
||||||
|
|
||||||
|
Phases 2 to 5 are dropped. The whole auth process is being rebuilt to run on a server instead of locally, and the roadmap will be redrawn around it. Don't start any of the old phases. Phase 1 (token renewal, saved sign-in, `ADMIN_KEY`) still works today, but the rebuild may replace it. See `docs/ROADMAP.md`.
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
1. **Check phase 1 against live Zoho** (steps above), then commit it so the board keeps running in the meantime.
|
||||||
|
2. **Plan the server-based auth rebuild** and rewrite the phases in `docs/ROADMAP.md` around it.
|
||||||
|
|
||||||
|
## To pick up
|
||||||
|
|
||||||
|
Start a Claude Code session in this repo and say something like:
|
||||||
|
|
||||||
|
*"Read SESSION.md and docs/ROADMAP.md. The old phases 2 to 5 are dropped, and I'm rebuilding auth to run on a server instead of locally. Help me plan that rebuild and redraw the roadmap around it."*
|
||||||
30
docs/NOTES.md
Normal file
30
docs/NOTES.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Notes
|
||||||
|
|
||||||
|
## SDP v3 API gotchas
|
||||||
|
|
||||||
|
Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/search-criteria.html>
|
||||||
|
|
||||||
|
- **Condition names have spaces:** `greater than`, `is not`, `lesser than`. `greater_than` isn't valid, and SDP silently ignored the **entire** search criteria because of it, returning every ticket (124,050). If a query's `total_count` is huge, suspect the criteria first.
|
||||||
|
- **Paging:** `row_count` is at most 100. Use `start_index`, and stop when `list_info.has_more_rows` is false. Ask for `get_total_count: true` on the first page. `getAllRequests` dedupes by ID and stops at 20 pages or when the total is unreachable.
|
||||||
|
- **Multiple values:** use a `values: [...]` array, and join criteria with `logical_operator: 'AND'` (see `allOf()` in `tickets.js`).
|
||||||
|
- **"Technician is empty":** written as `{ field: 'technician', condition: 'is' }` with no value. This isn't documented, since the docs only show the "is not" form, but it's verified to work.
|
||||||
|
- **Field names that work in filters:** `site.name`, `group.name`, `technician.name`, `status.name`, `last_updated_time`.
|
||||||
|
- **Group names:** the ManageEngine UI shows groups as "Customer Support « KVCC", but the API name is plain "Customer Support".
|
||||||
|
- **Sign-in scope:** `SDPOnDemand.requests.READ` is enough for everything so far.
|
||||||
|
- **Token lifetime:** Zoho access tokens last about an hour. The device flow only returns a `refresh_token` because the device-code request asks for `access_type: offline`. Renew it with `POST accounts.zoho.com/oauth/v2/token`, `grant_type=refresh_token`.
|
||||||
|
- **Zoho errors come back as HTTP 200:** a rejected refresh token returns `{ "error": "invalid_code" }` with status 200, so check for `access_token` rather than relying on axios throwing. The device-token poll works the same way (`authorization_pending`).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **2026-10-15:** **MIT license.** Copyright Skylar Grant.
|
||||||
|
- **2026-10-15:** **OpenWeatherMap** for weather. The free tier covers current weather and the 5-day forecast, and results are cached for 10 minutes. The National Weather Service API was the alternative (free, no key), but it needs three calls per lookup.
|
||||||
|
- **2026-10-15:** **Unassigned counter** uses its own status exclusions (`UNASSIGNED_EXCLUDED_STATUSES`) to match the ManageEngine view. The debug list is kept but hidden (`SHOW_UNASSIGNED_LIST` in `index.html`).
|
||||||
|
- **2026-10-15:** **Multi-region approach:** one service token plus region config, not per-user Zoho tokens. A user's own token could see all 124k tickets, so it wouldn't separate regions, and per-user polling multiplies API traffic. See `ROADMAP.md`.
|
||||||
|
|
||||||
|
- **2026-10-15:** **Admin key until real sign-in.** `ADMIN_KEY` in `.env`, sent as `X-Admin-Key`, guards `/api/connect` and `/api/check`. With no key set, only loopback requests are admin. Loopback isn't trusted once a key is set, because behind a reverse proxy every request is loopback. Non-admins never see the device code; otherwise anyone could approve the board with their own Zoho account.
|
||||||
|
- **2026-10-15:** **Token saved to a JSON file** (`data/token.json`, mode 600, written atomically), not SQLite yet. Move it into the database in phase 2 if one exists by then. Renewal failures with a 400 or a Zoho `error` drop the token and delete the file; network errors keep it and retry on the next check.
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
- The status table scrolls sideways once there are more statuses than fit, because the page is capped at 1,320px. Consider widening the page or narrowing the sidebar.
|
||||||
|
- All statuses not in `STATUS_NAMES` count as open, including "Need Information" and "On Hold". This is intentional for now; revisit when regions get their own config.
|
||||||
34
docs/ROADMAP.md
Normal file
34
docs/ROADMAP.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A hosted dashboard where staff in each MCCS region sign in and see their own region's queue. It should be customisable per user, and easy to extend with new panels.
|
||||||
|
|
||||||
|
## Target architecture
|
||||||
|
|
||||||
|
- **Identity:** users sign in (Zoho web OAuth, or Microsoft if MCCS staff use it) only to identify themselves.
|
||||||
|
- **Data:** one service connection, signed in once by an admin, with its refresh token kept and auto-renewed. Its SDP account must be able to see every region.
|
||||||
|
- **Regions:** profiles stored in SQLite (sites, groups, technicians, statuses). Checks run once per region and the results are cached, so API traffic grows with regions, not viewers.
|
||||||
|
- **Access:** users are assigned to regions and the server enforces it. An admin role manages regions and assignments.
|
||||||
|
- **Extensibility:** each panel is a module with server code to fetch its data and page code to draw it. The weather card is the first example.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
Phases 2 to 5 (regions as configuration, user sign-in, admin pages, deployment) were dropped on 2026-10-15. Don't start them. The whole auth process is being rebuilt to run on a server instead of locally, and the plan after phase 1 will be redrawn around that. Until then, the Identity, Data and Access bullets in the target architecture above may be out of date. The open questions below still need answering.
|
||||||
|
- [ ] **Later:** per-user preferences (default region, hidden columns, sound) and panel modules.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Are regions a **security boundary** (staff can't see other regions) or a **convenience** (default view with a switcher)?
|
||||||
|
- Do MCCS staff sign in with Microsoft 365 or Entra ID? That decides between Zoho and Microsoft sign-in.
|
||||||
|
- Does SDP itself restrict technicians to certain sites? If it does, per-user tokens become an option.
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
- Dashboard: stat tiles, technician × status table, live closed feed with the ding.
|
||||||
|
- Central region scoping by site and group.
|
||||||
|
- Unassigned counter. Verified to match the ManageEngine "Central Region Unassigned" view on 2026-10-15.
|
||||||
|
- Weather card (OpenWeatherMap).
|
||||||
|
- Fixed SDP paging and the date filter (see `NOTES.md`).
|
||||||
|
- Phase 1: the Zoho refresh token is kept, the access token is renewed 5 minutes before it expires, and the sign-in is saved to `data/token.json` (`TOKEN_FILE`) so it survives restarts.
|
||||||
|
- Phase 1b: Connect and Refresh are admin-only (`ADMIN_KEY`, or localhost when no key is set), and only admins see the device code. Phase 3 replaces the key with real sign-in.
|
||||||
BIN
images/1.png
BIN
images/1.png
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 28 KiB |
BIN
images/2.png
BIN
images/2.png
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 32 KiB |
BIN
images/4.png
BIN
images/4.png
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 73 KiB |
24
package-lock.json
generated
24
package-lock.json
generated
@ -1,14 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "Dashboard",
|
"name": "sdp-request-watcher",
|
||||||
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"name": "sdp-request-watcher",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "1.x",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "18.x",
|
||||||
"express": "^5.2.1"
|
"express": "5.x"
|
||||||
}
|
},
|
||||||
|
"devDependencies": {}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@ -228,10 +233,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.4.2",
|
"version": "18.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz",
|
||||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
"integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"bin": {
|
||||||
|
"dotenv": "dist/index.cjs"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
13
package.json
13
package.json
@ -6,17 +6,22 @@
|
|||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.vfsh.dev/sgrant/sdp-request-watcher.git"
|
"url": "https://git.vfsh.dev/sgrant/sdp-request-watcher.git"
|
||||||
},
|
},
|
||||||
"license": "ISC",
|
"license": "MIT",
|
||||||
"author": "Skylar Grant",
|
"author": "Skylar Grant",
|
||||||
|
"contributors": [
|
||||||
|
"Isaac Godin",
|
||||||
|
"Claude (Anthropic) <noreply@anthropic.com>"
|
||||||
|
],
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"app": "node src/server.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "1.x",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "18.x",
|
||||||
"express": "^5.2.1"
|
"express": "5.x"
|
||||||
},
|
},
|
||||||
"devDependencies": {}
|
"devDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/auth.js
43
src/auth.js
@ -34,7 +34,7 @@ function pollForToken(clientId, clientSecret, deviceCode, interval, expiresIn) {
|
|||||||
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams);
|
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams);
|
||||||
if (res.data.access_token) {
|
if (res.data.access_token) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
resolve(res.data.access_token);
|
resolve(res.data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
}
|
}
|
||||||
@ -54,7 +54,7 @@ async function startAuthentication({ clientId, clientSecret, state }) {
|
|||||||
console.log('Code: ', deviceData.user_code);
|
console.log('Code: ', deviceData.user_code);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accessToken = await pollForToken(
|
const tokenData = await pollForToken(
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
deviceData.device_code,
|
deviceData.device_code,
|
||||||
@ -65,7 +65,17 @@ async function startAuthentication({ clientId, clientSecret, state }) {
|
|||||||
state.auth = 'connected';
|
state.auth = 'connected';
|
||||||
state.verificationUrl = null;
|
state.verificationUrl = null;
|
||||||
state.userCode = null;
|
state.userCode = null;
|
||||||
return { accessToken };
|
|
||||||
|
// only issued because the device code asked for access_type=offline
|
||||||
|
if (!tokenData.refresh_token) {
|
||||||
|
console.warn('Zoho sent no refresh token; the board will stop when the access token expires');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: tokenData.access_token,
|
||||||
|
refreshToken: tokenData.refresh_token || null,
|
||||||
|
expiresAt: Date.now() + (tokenData.expires_in || 3600) * 1000
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.auth = 'error';
|
state.auth = 'error';
|
||||||
state.lastError = err.message;
|
state.lastError = err.message;
|
||||||
@ -75,6 +85,31 @@ async function startAuthentication({ clientId, clientSecret, state }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swaps the long-lived refresh token for a new access token (they last about an hour).
|
||||||
|
async function refreshAccessToken({ clientId, clientSecret, refreshToken }) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: 'refresh_token'
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await axios.post('https://accounts.zoho.com/oauth/v2/token', params);
|
||||||
|
|
||||||
|
// zoho reports a bad refresh token as a 200 with an error field, not an http error
|
||||||
|
if (!res.data || !res.data.access_token) {
|
||||||
|
const err = new Error('Zoho refused the saved sign-in (' + (res.data?.error || 'no access token') + ')');
|
||||||
|
err.revoked = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: res.data.access_token,
|
||||||
|
expiresAt: Date.now() + (res.data.expires_in || 3600) * 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
startAuthentication
|
startAuthentication,
|
||||||
|
refreshAccessToken
|
||||||
};
|
};
|
||||||
@ -1,7 +1,10 @@
|
|||||||
const { startAuthentication } = require('./auth');
|
const { startAuthentication, refreshAccessToken } = require('./auth');
|
||||||
const { fetchClosedTickets } = require('./tickets');
|
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
|
||||||
|
|
||||||
function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes }) {
|
// renew this long before expiry so a slow check never runs on a dead token
|
||||||
|
const RENEW_EARLY_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function createBoardService({ clientId, clientSecret, tokenStore, technicians, statusSet, closedStatuses, unassignedExcludedStatuses, scope, windowMinutes, checkMinutes, workloadMinutes }) {
|
||||||
const state = {
|
const state = {
|
||||||
auth: 'disconnected',
|
auth: 'disconnected',
|
||||||
verificationUrl: null,
|
verificationUrl: null,
|
||||||
@ -10,11 +13,84 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
lastError: null
|
lastError: null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const workload = {
|
||||||
|
updatedAt: null,
|
||||||
|
error: null,
|
||||||
|
truncated: false,
|
||||||
|
statuses: [],
|
||||||
|
technicians: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const unassigned = {
|
||||||
|
updatedAt: null,
|
||||||
|
error: null,
|
||||||
|
truncated: false,
|
||||||
|
count: 0,
|
||||||
|
discarded: 0,
|
||||||
|
tickets: []
|
||||||
|
};
|
||||||
|
|
||||||
const tickets = new Map();
|
const tickets = new Map();
|
||||||
let accessToken = null;
|
// { accessToken, refreshToken, expiresAt }
|
||||||
|
let token = null;
|
||||||
|
let renewing = null;
|
||||||
let pollingTimer = null;
|
let pollingTimer = null;
|
||||||
|
let workloadTimer = null;
|
||||||
|
|
||||||
|
function saveToken() {
|
||||||
|
try {
|
||||||
|
tokenStore.save(token);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Could not save token, it will be lost on restart:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the saved sign-in is no good any more; go back to needing an admin to connect
|
||||||
|
function dropToken(message) {
|
||||||
|
token = null;
|
||||||
|
tokenStore.clear();
|
||||||
|
stopPolling();
|
||||||
|
state.auth = 'error';
|
||||||
|
state.lastError = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renewToken() {
|
||||||
|
try {
|
||||||
|
const renewed = await refreshAccessToken({ clientId, clientSecret, refreshToken: token.refreshToken });
|
||||||
|
token = { ...token, ...renewed };
|
||||||
|
saveToken();
|
||||||
|
console.log('Access token renewed');
|
||||||
|
} catch (err) {
|
||||||
|
// a 400 from zoho means the refresh token itself was rejected; anything else is
|
||||||
|
// probably the network, so keep the token and try again on the next check
|
||||||
|
if (err.revoked || err.response?.status === 400) {
|
||||||
|
dropToken(err.message + '. An admin needs to connect again.');
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAccessToken() {
|
||||||
|
if (!token) return null;
|
||||||
|
if (Date.now() < token.expiresAt - RENEW_EARLY_MS) return token.accessToken;
|
||||||
|
if (!token.refreshToken) {
|
||||||
|
dropToken('The Zoho sign-in expired and there is no refresh token. An admin needs to connect again.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the closed and workload checks can both land here at once; renew only once
|
||||||
|
if (!renewing) {
|
||||||
|
renewing = renewToken().finally(() => {
|
||||||
|
renewing = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await renewing;
|
||||||
|
return token ? token.accessToken : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshTickets() {
|
async function refreshTickets() {
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
|
|
||||||
await fetchClosedTickets({
|
await fetchClosedTickets({
|
||||||
@ -23,10 +99,31 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
tickets,
|
tickets,
|
||||||
technicians,
|
technicians,
|
||||||
statusSet,
|
statusSet,
|
||||||
|
scope,
|
||||||
windowMinutes
|
windowMinutes
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshWorkload() {
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
if (!accessToken) return;
|
||||||
|
|
||||||
|
await fetchWorkload({
|
||||||
|
accessToken,
|
||||||
|
workload,
|
||||||
|
technicians,
|
||||||
|
closedStatuses,
|
||||||
|
scope
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchUnassigned({
|
||||||
|
accessToken,
|
||||||
|
unassigned,
|
||||||
|
excludedStatuses: unassignedExcludedStatuses,
|
||||||
|
scope
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
if (pollingTimer) return;
|
if (pollingTimer) return;
|
||||||
|
|
||||||
@ -36,6 +133,33 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
state.lastCheck = Date.now();
|
state.lastCheck = Date.now();
|
||||||
});
|
});
|
||||||
}, checkMinutes * 60 * 1000);
|
}, checkMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
workloadTimer = setInterval(() => {
|
||||||
|
refreshWorkload().catch(err => {
|
||||||
|
workload.error = err.message;
|
||||||
|
workload.updatedAt = Date.now();
|
||||||
|
});
|
||||||
|
}, workloadMinutes * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
clearInterval(pollingTimer);
|
||||||
|
clearInterval(workloadTimer);
|
||||||
|
pollingTimer = null;
|
||||||
|
workloadTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startChecks() {
|
||||||
|
// timers first, so a failed first check (say the network is down at boot) still retries
|
||||||
|
startPolling();
|
||||||
|
await refreshTickets().catch(err => {
|
||||||
|
state.lastError = err.message;
|
||||||
|
state.lastCheck = Date.now();
|
||||||
|
});
|
||||||
|
await refreshWorkload().catch(err => {
|
||||||
|
workload.error = err.message;
|
||||||
|
workload.updatedAt = Date.now();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
@ -47,11 +171,23 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
|
|
||||||
if (!authResult?.accessToken) return;
|
if (!authResult?.accessToken) return;
|
||||||
|
|
||||||
accessToken = authResult.accessToken;
|
token = authResult;
|
||||||
|
saveToken();
|
||||||
console.log('Authorization successful');
|
console.log('Authorization successful');
|
||||||
|
|
||||||
await refreshTickets();
|
await startChecks();
|
||||||
startPolling();
|
}
|
||||||
|
|
||||||
|
// picks up the sign-in saved by a previous run, if there is one
|
||||||
|
async function resume() {
|
||||||
|
const saved = tokenStore.load();
|
||||||
|
if (!saved) return;
|
||||||
|
|
||||||
|
token = saved;
|
||||||
|
state.auth = 'connected';
|
||||||
|
console.log('Resumed saved Zoho sign-in');
|
||||||
|
|
||||||
|
await startChecks();
|
||||||
}
|
}
|
||||||
|
|
||||||
function failConnection(err) {
|
function failConnection(err) {
|
||||||
@ -66,18 +202,23 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
|||||||
...state,
|
...state,
|
||||||
windowMinutes,
|
windowMinutes,
|
||||||
checkMinutes,
|
checkMinutes,
|
||||||
|
workloadMinutes,
|
||||||
watching: technicians,
|
watching: technicians,
|
||||||
|
scope,
|
||||||
serverTime: Date.now(),
|
serverTime: Date.now(),
|
||||||
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
|
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt),
|
||||||
|
workload,
|
||||||
|
unassigned
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkNow() {
|
async function checkNow() {
|
||||||
await refreshTickets();
|
await Promise.all([refreshTickets(), refreshWorkload()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connect,
|
connect,
|
||||||
|
resume,
|
||||||
checkNow,
|
checkNow,
|
||||||
failConnection,
|
failConnection,
|
||||||
getState
|
getState
|
||||||
|
|||||||
@ -2,3 +2,18 @@ CLIENT_ID=
|
|||||||
CLIENT_SECRET=
|
CLIENT_SECRET=
|
||||||
TECHNICIAN_NAMES=First Last, First Last
|
TECHNICIAN_NAMES=First Last, First Last
|
||||||
STATUS_NAMES=Closed,Resolved
|
STATUS_NAMES=Closed,Resolved
|
||||||
|
# only requests from these sites are shown (central region: KVCC, EMCC, CMCC, MCCS and their satellite sites)
|
||||||
|
SITE_NAMES=Base Site,ASSIGN,CMCC,CMCC-Damariscotta,EMCC,KVCC,KVCC-Alfond,MCCS
|
||||||
|
# optional: also limit to these support groups, e.g. Customer Support (leave empty for all groups)
|
||||||
|
GROUP_NAMES=
|
||||||
|
# statuses the Unassigned Requests counter leaves out (defaults to STATUS_NAMES)
|
||||||
|
UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved
|
||||||
|
# optional: weather widget, free key from https://openweathermap.org/api (current weather + 5 day forecast)
|
||||||
|
OWM_API_KEY=
|
||||||
|
OWM_ZIP_CODE=04937
|
||||||
|
# optional: how often the technician/status table is recounted, in minutes (default 5)
|
||||||
|
WORKLOAD_MINUTES=5
|
||||||
|
# optional: key for Connect and Refresh now (enter it with the page's Admin button). Without it, only this machine can use them
|
||||||
|
ADMIN_KEY=
|
||||||
|
# optional: where the Zoho sign-in is saved so it survives restarts (default data/token.json)
|
||||||
|
TOKEN_FILE=
|
||||||
|
|||||||
@ -3,19 +3,20 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Closed ticket board</title>
|
<title>Help desk dashboard</title>
|
||||||
|
<meta name="author" content="Skylar Grant, Isaac Godin (design), Claude by Anthropic">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|
||||||
--ground: hsl(210, 18%, 8%);
|
--ground: hsl(210, 18%, 8%);
|
||||||
--card: hsl(210, 16%, 13%);
|
--card: hsl(210, 16%, 13%);
|
||||||
|
--card-raised: hsl(210, 15%, 16%);
|
||||||
--ink: hsl(30, 30%, 96%);
|
--ink: hsl(30, 30%, 96%);
|
||||||
--muted: hsl(215, 12%, 68%);
|
--muted: hsl(215, 12%, 68%);
|
||||||
|
--faint: hsl(215, 10%, 42%);
|
||||||
--rule: hsl(210, 12%, 24%);
|
--rule: hsl(210, 12%, 24%);
|
||||||
--signal: hsl(28, 95%, 58%);
|
--signal: hsl(28, 95%, 58%);
|
||||||
--signal-soft: hsl(28, 70%, 22%);
|
--signal-soft: hsl(28, 70%, 22%);
|
||||||
@ -27,6 +28,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -42,10 +44,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
max-width: 860px;
|
width: 100%;
|
||||||
|
max-width: 1320px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 20px 80px;
|
padding: 28px 24px 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- header ---- */
|
/* ---- header ---- */
|
||||||
@ -53,7 +56,7 @@ body {
|
|||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 12px 20px;
|
gap: 12px 20px;
|
||||||
padding-bottom: 14px;
|
padding-bottom: 14px;
|
||||||
border-bottom: 2px solid var(--ink);
|
border-bottom: 2px solid var(--ink);
|
||||||
@ -71,7 +74,6 @@ h1 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
margin-left: auto;
|
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@ -96,6 +98,13 @@ h1 {
|
|||||||
50% { opacity: 0.25; }
|
50% { opacity: 0.25; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -124,7 +133,7 @@ h1 {
|
|||||||
.panel p {
|
.panel p {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
max-width: 52ch;
|
max-width: 80ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code {
|
.code {
|
||||||
@ -165,6 +174,40 @@ button.quiet {
|
|||||||
text-underline-offset: 3px;
|
text-underline-offset: 3px;
|
||||||
}
|
}
|
||||||
button.quiet:hover { background: none; color: var(--ink); }
|
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 {
|
.error {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
@ -175,55 +218,188 @@ button.quiet:hover { background: none; color: var(--ink); }
|
|||||||
}
|
}
|
||||||
.error strong { display: block; font-family: var(--mono); font-size: 12px; color: var(--alert); }
|
.error strong { display: block; font-family: var(--mono); font-size: 12px; color: var(--alert); }
|
||||||
|
|
||||||
/* ---- tallies ---- */
|
/* ---- stat tiles ---- */
|
||||||
|
|
||||||
.tallies {
|
.stats {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tally {
|
.stat {
|
||||||
display: flex;
|
padding: 16px 18px;
|
||||||
align-items: baseline;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 5px 12px;
|
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
.tally b {
|
.stat .label {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.stat .value {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 30px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--signal);
|
line-height: 1.1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stat.accent .value { color: var(--signal); }
|
||||||
|
.stat .sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- board layout ---- */
|
||||||
|
|
||||||
|
.board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 380px;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 12px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.section-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.section-head .note {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- workload table ---- */
|
||||||
|
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 9px 14px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
th .swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-right: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--status-color, var(--muted));
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:first-child, td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background: var(--card);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
td:first-child { font-weight: 500; }
|
||||||
|
|
||||||
|
tbody tr + tr td { border-top: 1px solid color-mix(in oklab, var(--rule), transparent 50%); }
|
||||||
|
tbody tr:hover td { background: var(--card-raised); }
|
||||||
|
|
||||||
|
td.num {
|
||||||
|
--heat: 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: color-mix(in oklab, var(--signal-soft) calc(var(--heat) * 100%), transparent);
|
||||||
|
}
|
||||||
|
tbody tr:hover td.num { background: color-mix(in oklab, var(--signal-soft) calc(var(--heat) * 100%), var(--card-raised)); }
|
||||||
|
td.zero { color: var(--faint); }
|
||||||
|
|
||||||
|
/* totals and closed-today sit apart from the per-status columns */
|
||||||
|
td.total { font-weight: 600; background: none; }
|
||||||
|
th.closed, td.closed { border-right: 1px solid var(--rule); }
|
||||||
|
td.closed { color: var(--signal); background: none; }
|
||||||
|
td.closed.zero { color: var(--faint); }
|
||||||
|
tfoot td.closed { color: var(--ink); }
|
||||||
|
|
||||||
|
tfoot td {
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
tfoot td:first-child { font-family: var(--font-ui); color: var(--muted); font-weight: 500; }
|
||||||
|
|
||||||
|
tr.flash td { animation: flash 2.4s ease-out; }
|
||||||
|
@keyframes flash {
|
||||||
|
from { background: var(--signal-soft); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-empty {
|
||||||
|
padding: 36px 18px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- feed ---- */
|
/* ---- feed ---- */
|
||||||
|
|
||||||
.feed { margin-top: 20px; }
|
.feed {
|
||||||
|
max-height: 640px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.ticket {
|
.ticket {
|
||||||
--age: 0;
|
--age: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 74px 1fr;
|
grid-template-columns: 66px 1fr;
|
||||||
background: var(--card);
|
border-bottom: 1px solid var(--rule);
|
||||||
border: 1px solid var(--rule);
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
.feed .ticket:last-child { border-bottom: 1px solid var(--rule); }
|
.feed .ticket:last-child { border-bottom: none; }
|
||||||
|
|
||||||
/* The rail is the point of the whole page: how long ago this closed, drained
|
/* how long ago this closed, drained of color as it ages out of the hour */
|
||||||
of color as it ages out of the window. */
|
|
||||||
.rail {
|
.rail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 14px 0 14px 14px;
|
padding: 12px 0 12px 12px;
|
||||||
border-left: 3px solid color-mix(in oklab, var(--signal), var(--rule) calc(var(--age) * 100%));
|
border-left: 3px solid color-mix(in oklab, var(--signal), var(--rule) calc(var(--age) * 100%));
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: color-mix(in oklab, var(--signal), var(--muted) calc(var(--age) * 100%));
|
color: color-mix(in oklab, var(--signal), var(--muted) calc(var(--age) * 100%));
|
||||||
}
|
}
|
||||||
@ -235,11 +411,11 @@ button.quiet:hover { background: none; color: var(--ink); }
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.body { padding: 14px 16px 14px 4px; min-width: 0; }
|
.body { padding: 12px 14px 12px 4px; min-width: 0; }
|
||||||
|
|
||||||
.subject {
|
.subject {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@ -251,8 +427,8 @@ button.quiet:hover { background: none; color: var(--ink); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.byline {
|
.byline {
|
||||||
margin: 3px 0 0;
|
margin: 2px 0 0;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.byline b { color: var(--ink); font-weight: 500; }
|
.byline b { color: var(--ink); font-weight: 500; }
|
||||||
@ -264,32 +440,103 @@ button.quiet:hover { background: none; color: var(--ink); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
margin-top: 20px;
|
|
||||||
padding: 40px 24px;
|
padding: 40px 24px;
|
||||||
background: var(--card);
|
|
||||||
border: 1px dashed var(--rule);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.empty p { margin: 0; }
|
.empty p { margin: 0; }
|
||||||
|
|
||||||
footer {
|
|
||||||
margin-top: 28px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
body > footer {
|
body > footer {
|
||||||
margin-top: auto;
|
|
||||||
max-width: 860px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 20px 28px;
|
max-width: 1320px;
|
||||||
|
margin: auto auto 0;
|
||||||
|
padding: 0 24px 28px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
body > footer p { margin: 0; }
|
||||||
|
body > footer p + p { margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ---- weather ---- */
|
||||||
|
|
||||||
|
.side { display: grid; gap: 20px; min-width: 0; }
|
||||||
|
|
||||||
|
.weather-now {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 12px;
|
||||||
|
padding: 12px 18px 14px 8px;
|
||||||
|
}
|
||||||
|
.weather-now img { width: 84px; height: 84px; margin: -6px 0; }
|
||||||
|
.weather-temp {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 38px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.weather-desc { margin-top: 4px; font-size: 14px; text-transform: capitalize; }
|
||||||
|
.weather-details {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px 14px;
|
||||||
|
padding-left: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.weather-details b { color: var(--ink); font-weight: 500; }
|
||||||
|
|
||||||
|
.forecast {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.forecast-day {
|
||||||
|
padding: 10px 4px 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.forecast-day + .forecast-day { border-left: 1px solid color-mix(in oklab, var(--rule), transparent 50%); }
|
||||||
|
.forecast-day .name { color: var(--muted); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||||
|
.forecast-day img { display: block; width: 44px; height: 44px; margin: 0 auto; }
|
||||||
|
.forecast-day .lo { color: var(--muted); }
|
||||||
|
.forecast-day .pop { margin-top: 2px; font-size: 11px; color: hsl(205, 70%, 66%); }
|
||||||
|
|
||||||
|
/* ---- temporary: unassigned verification list (not for prod) ---- */
|
||||||
|
|
||||||
|
.verify {
|
||||||
|
margin-top: 20px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px dashed var(--rule);
|
||||||
|
}
|
||||||
|
.verify summary {
|
||||||
|
padding: 12px 18px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.verify summary:hover { color: var(--ink); }
|
||||||
|
.verify[open] summary { border-bottom: 1px dashed var(--rule); }
|
||||||
|
.verify .warn { padding: 10px 18px 0; font-size: 13px; color: var(--alert); }
|
||||||
|
.verify td, .verify th { text-align: left; }
|
||||||
|
.verify td.subject-cell { white-space: normal; min-width: 260px; }
|
||||||
|
|
||||||
|
@media (max-width: 1040px) {
|
||||||
|
.board { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.feed { max-height: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 720px) {
|
||||||
.ticket { grid-template-columns: 62px 1fr; }
|
.wrap { padding: 20px 16px 40px; }
|
||||||
.rail { font-size: 13px; padding-left: 10px; }
|
body > footer { padding: 0 16px 24px; }
|
||||||
|
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.stat .value { font-size: 24px; }
|
||||||
|
.actions { margin-left: 0; width: 100%; }
|
||||||
|
th, td { padding: 8px 10px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@ -301,17 +548,22 @@ body > footer {
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>Closed ticket board</h1>
|
<h1>Help desk dashboard</h1>
|
||||||
<div class="status" id="status" data-auth="disconnected">
|
<div class="status" id="status" data-auth="disconnected">
|
||||||
<span class="dot"></span><span id="statusText">Not connected</span>
|
<span class="dot"></span><span id="statusText">Not connected</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="toggle" id="soundToggle" aria-pressed="true" onclick="toggleSound()">
|
||||||
|
<span class="track"></span>Sound on close
|
||||||
|
</button>
|
||||||
|
<button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button>
|
||||||
|
<button class="quiet" id="adminBtn" onclick="toggleAdmin()" hidden>Admin</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="meta" id="meta"></div>
|
<div class="meta" id="meta"></div>
|
||||||
|
|
||||||
<div id="main"></div>
|
<div id="main"></div>
|
||||||
|
|
||||||
<footer id="footer"></footer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -327,11 +579,54 @@ function beep() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the ding is on by default; muting is remembered per browser
|
||||||
|
let soundOn = true;
|
||||||
|
try { soundOn = localStorage.getItem('sdp-sound') !== 'off'; } catch (err) {}
|
||||||
|
|
||||||
|
function toggleSound() {
|
||||||
|
soundOn = !soundOn;
|
||||||
|
try { localStorage.setItem('sdp-sound', soundOn ? 'on' : 'off'); } catch (err) {}
|
||||||
|
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
|
||||||
|
if (soundOn) beep();
|
||||||
|
}
|
||||||
|
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
|
||||||
|
|
||||||
|
// connect and refresh are admin-only; the key is remembered per browser and sent with every call
|
||||||
|
let adminKey = '';
|
||||||
|
try { adminKey = localStorage.getItem('sdp-admin-key') || ''; } catch (err) {}
|
||||||
|
|
||||||
|
function api(url, options = {}) {
|
||||||
|
return fetch(url, { ...options, headers: adminKey ? { 'X-Admin-Key': adminKey } : {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdminKey(key) {
|
||||||
|
adminKey = key;
|
||||||
|
try {
|
||||||
|
if (key) localStorage.setItem('sdp-admin-key', key);
|
||||||
|
else localStorage.removeItem('sdp-admin-key');
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAdmin() {
|
||||||
|
if (adminKey) {
|
||||||
|
setAdminKey('');
|
||||||
|
return load();
|
||||||
|
}
|
||||||
|
const key = prompt('Admin key');
|
||||||
|
if (!key) return;
|
||||||
|
setAdminKey(key.trim());
|
||||||
|
await load();
|
||||||
|
if (data && !data.isAdmin) {
|
||||||
|
setAdminKey('');
|
||||||
|
alert('That admin key was not accepted.');
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let data = null;
|
let data = null;
|
||||||
let seen = new Set();
|
let seen = new Set();
|
||||||
let firstLoad = true;
|
let firstLoad = true;
|
||||||
|
let flashTechs = new Set();
|
||||||
|
|
||||||
const STATUS_TEXT = {
|
const STATUS_TEXT = {
|
||||||
disconnected: 'Not connected',
|
disconnected: 'Not connected',
|
||||||
@ -358,7 +653,7 @@ function esc(text) {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/state');
|
const res = await api('/api/state');
|
||||||
data = await res.json();
|
data = await res.json();
|
||||||
render();
|
render();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -369,22 +664,36 @@ async function load() {
|
|||||||
function render() {
|
function render() {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
if (!firstLoad && data.tickets.some(t => !seen.has(t.id))) beep(); //Calls Beep
|
// the watcher: anything we haven't seen before is a fresh close
|
||||||
|
const fresh = data.tickets.filter(t => !seen.has(t.id));
|
||||||
|
flashTechs = new Set(firstLoad ? [] : fresh.map(t => t.technician));
|
||||||
|
if (!firstLoad && fresh.length && soundOn) beep();
|
||||||
|
|
||||||
$('status').dataset.auth = data.auth;
|
$('status').dataset.auth = data.auth;
|
||||||
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
|
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
|
||||||
|
$('checkBtn').hidden = data.auth !== 'connected' || !data.isAdmin;
|
||||||
|
// without a key configured, admin just means "on the server's machine"; nothing to enter
|
||||||
|
$('adminBtn').hidden = !data.adminKeyRequired;
|
||||||
|
$('adminBtn').textContent = adminKey ? 'Lock' : 'Admin';
|
||||||
|
|
||||||
|
const wl = data.workload || {};
|
||||||
$('meta').innerHTML = [
|
$('meta').innerHTML = [
|
||||||
'Checks every ' + data.checkMinutes + ' min',
|
'Closed tickets checked every ' + data.checkMinutes + ' min',
|
||||||
'Window ' + data.windowMinutes + ' min',
|
'Status counts every ' + data.workloadMinutes + ' min',
|
||||||
data.watching.length ? 'Watching ' + data.watching.length + ' technicians' : 'Watching all technicians',
|
data.watching.length ? 'Watching ' + data.watching.length + ' technicians' : 'Watching all technicians',
|
||||||
|
data.scope && data.scope.sites.length ? 'Sites: ' + data.scope.sites.join(', ') : '',
|
||||||
|
data.scope && data.scope.groups.length ? 'Groups: ' + data.scope.groups.join(', ') : '',
|
||||||
data.lastCheck ? 'Last check ' + clockTime(data.lastCheck) : ''
|
data.lastCheck ? 'Last check ' + clockTime(data.lastCheck) : ''
|
||||||
].filter(Boolean).map(t => '<span>' + esc(t) + '</span>').join('');
|
].filter(Boolean).map(t => '<span>' + esc(t) + '</span>').join('');
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
if (data.auth === 'disconnected' || data.auth === 'error') {
|
if ((data.auth === 'disconnected' || data.auth === 'error') && !data.isAdmin) {
|
||||||
|
html += `<div class="panel">
|
||||||
|
<h2>Not connected to the help desk</h2>
|
||||||
|
<p>An admin needs to connect this board to Zoho. It starts updating on its own once they do.</p>
|
||||||
|
</div>`;
|
||||||
|
} else if (data.auth === 'disconnected' || data.auth === 'error') {
|
||||||
html += `<div class="panel">
|
html += `<div class="panel">
|
||||||
<h2>Connect to the help desk</h2>
|
<h2>Connect to the help desk</h2>
|
||||||
<p>Zoho needs you to approve this board once. Connecting opens an authorization
|
<p>Zoho needs you to approve this board once. Connecting opens an authorization
|
||||||
@ -393,45 +702,219 @@ function render() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.auth === 'pending') {
|
if (data.auth === 'pending' && !data.isAdmin) {
|
||||||
|
html += `<div class="panel">
|
||||||
|
<h2>Connecting</h2>
|
||||||
|
<p>An admin is approving this board in Zoho.</p>
|
||||||
|
</div>`;
|
||||||
|
} else if (data.auth === 'pending' && !(data.userCode && data.verificationUrl)) {
|
||||||
|
// Zoho hasn't handed back the device code yet; nothing to open until it does
|
||||||
html += `<div class="panel">
|
html += `<div class="panel">
|
||||||
<h2>Approve the board</h2>
|
<h2>Approve the board</h2>
|
||||||
<p>Open the authorization page and confirm this code matches the one shown there.</p>
|
<p>Requesting a device code from Zoho…</p>
|
||||||
<div class="code">${esc(data.userCode || '...')}</div>
|
</div>`;
|
||||||
<a class="button" href="${esc(data.verificationUrl || '#')}">Open authorization page</a>
|
} else if (data.auth === 'pending') {
|
||||||
|
html += `<div class="panel">
|
||||||
|
<h2>Approve the board</h2>
|
||||||
|
<p>The authorization page opens in a new tab. Confirm this code matches the one shown there,
|
||||||
|
approve it, then close that tab. This page picks up the connection on its own.</p>
|
||||||
|
<div class="code">${esc(data.userCode)}</div>
|
||||||
|
<a class="button" href="${esc(data.verificationUrl)}" target="_blank" rel="noopener noreferrer">Open authorization page ↗</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.lastError) {
|
if (data.lastError) {
|
||||||
html += `<div class="error"><strong>Last check failed</strong>${esc(data.lastError)}</div>`;
|
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') {
|
if (data.auth === 'connected') {
|
||||||
const tallies = {};
|
html += renderStats(wl);
|
||||||
for (const t of data.tickets) tallies[t.technician] = (tallies[t.technician] || 0) + 1;
|
html += `<div class="board">
|
||||||
const names = Object.keys(tallies).sort((a, b) => tallies[b] - tallies[a]);
|
${renderWorkload(wl)}
|
||||||
|
<div class="side">
|
||||||
if (names.length) {
|
${renderWeather()}
|
||||||
html += '<div class="tallies">' + names.map(n =>
|
${renderFeed()}
|
||||||
`<span class="tally">${esc(n)} <b>${tallies[n]}</b></span>`).join('') + '</div>';
|
</div>
|
||||||
}
|
</div>`;
|
||||||
|
html += renderUnassignedList(data.unassigned || {});
|
||||||
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;
|
$('main').innerHTML = html;
|
||||||
|
if (verifyOpen) document.querySelector('.verify').open = true;
|
||||||
|
|
||||||
$('footer').textContent = data.tickets && data.tickets.length
|
for (const t of data.tickets) seen.add(t.id);
|
||||||
? data.tickets.length + ' tickets on the board'
|
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 + '%' : ' '}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const note = (weather.error ? 'stale · ' : '') + 'as of ' + clockTime(weather.updatedAt);
|
||||||
|
|
||||||
|
return `<section class="section">
|
||||||
|
<div class="section-head"><h2>${esc(weather.place || 'Weather')}</h2><span class="note">${esc(note)}</span></div>
|
||||||
|
<div class="weather-now">
|
||||||
|
${weather.icon ? `<img src="${iconUrl(weather.icon, 4)}" alt="">` : '<span></span>'}
|
||||||
|
<div>
|
||||||
|
<div class="weather-temp">${weather.temp}°F</div>
|
||||||
|
<div class="weather-desc">${esc(weather.description || weather.condition || '')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="weather-details">
|
||||||
|
<span>Feels <b>${weather.feelsLike}°</b></span>
|
||||||
|
<span>Wind <b>${weather.wind} mph</b></span>
|
||||||
|
<span>Humidity <b>${weather.humidity}%</b></span>
|
||||||
|
${weather.sunset ? `<span>Sunset <b>${clockTime(weather.sunset)}</b></span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${days ? `<div class="forecast">${days}</div>` : ''}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug list for comparing against the ManageEngine "Central Region Unassigned" view.
|
||||||
|
// Verified matching on 2026-10-15; flip to true to bring it back.
|
||||||
|
const SHOW_UNASSIGNED_LIST = false;
|
||||||
|
|
||||||
|
function renderUnassignedList(un) {
|
||||||
|
if (!SHOW_UNASSIGNED_LIST || !un.updatedAt || un.error) return '';
|
||||||
|
|
||||||
|
const rows = (un.tickets || []).map(t => `<tr>
|
||||||
|
<td class="num">#${esc(t.id)}</td>
|
||||||
|
<td class="subject-cell">${esc(t.subject || 'No subject')}</td>
|
||||||
|
<td>${esc(t.status || '')}</td>
|
||||||
|
<td>${esc(t.site || '')}</td>
|
||||||
|
<td>${esc(t.group || '')}</td>
|
||||||
|
<td>${esc(t.created || '')}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
const warn = un.discarded
|
||||||
|
? `<div class="warn">The API returned ${un.discarded} extra request(s) that failed the local check, so the search criteria aren't matching the view exactly.</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
firstLoad = false;
|
return `<details class="verify">
|
||||||
|
<summary>Verify: ${un.count} unassigned request${un.count === 1 ? '' : 's'} (temporary, click to expand)</summary>
|
||||||
|
${warn}
|
||||||
|
${rows
|
||||||
|
? `<div class="table-scroll"><table>
|
||||||
|
<thead><tr><th>ID</th><th>Subject</th><th>Status</th><th>Site</th><th>Group</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table></div>`
|
||||||
|
: '<div class="table-empty">No unassigned requests.</div>'}
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeed() {
|
||||||
|
const list = data.tickets.length
|
||||||
|
? '<div class="feed">' + data.tickets.map(renderTicket).join('') + '</div>'
|
||||||
|
: '<div class="empty"><p>Nothing closed yet.</p></div>';
|
||||||
|
|
||||||
|
return `<section class="section">
|
||||||
|
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.windowMinutes} min, kept 12h</span></div>
|
||||||
|
${list}
|
||||||
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTicket(ticket) {
|
function renderTicket(ticket) {
|
||||||
@ -440,7 +923,6 @@ function renderTicket(ticket) {
|
|||||||
// 0 = just closed, 1 = fully aged out (one hour on)
|
// 0 = just closed, 1 = fully aged out (one hour on)
|
||||||
const fade = Math.min(age / (60 * 60 * 1000), 1).toFixed(3);
|
const fade = Math.min(age / (60 * 60 * 1000), 1).toFixed(3);
|
||||||
const isNew = !firstLoad && !seen.has(ticket.id);
|
const isNew = !firstLoad && !seen.has(ticket.id);
|
||||||
seen.add(ticket.id);
|
|
||||||
|
|
||||||
return `<article class="ticket${isNew ? ' new' : ''}" style="--age:${fade}">
|
return `<article class="ticket${isNew ? ' new' : ''}" style="--age:${fade}">
|
||||||
<div class="rail">
|
<div class="rail">
|
||||||
@ -457,24 +939,46 @@ function renderTicket(ticket) {
|
|||||||
async function connect(btn) {
|
async function connect(btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Connecting...';
|
btn.textContent = 'Connecting...';
|
||||||
await fetch('/api/connect', { method: 'POST' });
|
await api('/api/connect', { method: 'POST' });
|
||||||
load();
|
// check quickly until the device code arrives instead of waiting on the 5s poll
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await load();
|
||||||
|
if (!data || data.auth !== 'pending' || data.userCode) break;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkNow(btn) {
|
async function checkNow(btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Checking...';
|
btn.textContent = 'Refreshing...';
|
||||||
await fetch('/api/check', { method: 'POST' });
|
try {
|
||||||
|
await api('/api/check', { method: 'POST' });
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Refresh now';
|
||||||
|
}
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let weather = null;
|
||||||
|
|
||||||
|
async function loadWeather() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/weather');
|
||||||
|
weather = await res.json();
|
||||||
|
render();
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
|
loadWeather();
|
||||||
setInterval(load, 5000); // pull fresh state from the server
|
setInterval(load, 5000); // pull fresh state from the server
|
||||||
|
setInterval(loadWeather, 10 * 60 * 1000); // server caches for 10 min, no point asking sooner
|
||||||
setInterval(render, 20000); // re-age the rails between pulls
|
setInterval(render, 20000); // re-age the rails between pulls
|
||||||
</script>
|
</script>
|
||||||
<footer>
|
<footer>
|
||||||
<p>Design and Credits: <span style="color: hsl(28, 95%, 58%);">Isaac Godin</span></p>
|
<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>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,47 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
function createRoutes(boardService) {
|
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get('/state', (req, res) => {
|
// Stand-in for real user sign-in (roadmap phase 3). With ADMIN_KEY set, the request must
|
||||||
res.json(boardService.getState());
|
// carry it in X-Admin-Key. Without it, only requests from this machine count as admin.
|
||||||
|
// Loopback isn't trusted once a key is set, since behind a reverse proxy everything is loopback.
|
||||||
|
function createAdminCheck(adminKey) {
|
||||||
|
const hash = value => crypto.createHash('sha256').update(String(value)).digest();
|
||||||
|
const keyHash = adminKey ? hash(adminKey) : null;
|
||||||
|
|
||||||
|
return function isAdmin(req) {
|
||||||
|
if (!keyHash) return LOOPBACK.has(req.socket.remoteAddress);
|
||||||
|
const given = req.get('X-Admin-Key');
|
||||||
|
return !!given && crypto.timingSafeEqual(hash(given), keyHash);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoutes(boardService, weatherService, { adminKey } = {}) {
|
||||||
|
const router = express.Router();
|
||||||
|
const isAdmin = createAdminCheck(adminKey);
|
||||||
|
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
if (isAdmin(req)) return next();
|
||||||
|
res.status(403).json({ ok: false, error: 'Admin only' });
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/weather', async (req, res) => {
|
||||||
|
res.json(await weatherService.getWeather());
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/connect', async (req, res) => {
|
router.get('/state', (req, res) => {
|
||||||
|
const admin = isAdmin(req);
|
||||||
|
const state = { ...boardService.getState(), isAdmin: admin, adminKeyRequired: !!adminKey };
|
||||||
|
// whoever holds the device code decides whose Zoho account the board runs as
|
||||||
|
if (!admin) {
|
||||||
|
state.verificationUrl = null;
|
||||||
|
state.userCode = null;
|
||||||
|
}
|
||||||
|
res.json(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/connect', requireAdmin, async (req, res) => {
|
||||||
const currentState = boardService.getState();
|
const currentState = boardService.getState();
|
||||||
if (currentState.auth === 'pending' || currentState.auth === 'connected') {
|
if (currentState.auth === 'pending' || currentState.auth === 'connected') {
|
||||||
return res.json({ ok: true });
|
return res.json({ ok: true });
|
||||||
@ -24,7 +58,7 @@ function createRoutes(boardService) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/check', async (req, res) => {
|
router.post('/check', requireAdmin, async (req, res) => {
|
||||||
await boardService.checkNow();
|
await boardService.checkNow();
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// sdp-request-watcher: help desk dashboard for ManageEngine SDP Cloud
|
||||||
|
// Skylar Grant, with Claude (Anthropic). Page design by Isaac Godin.
|
||||||
|
|
||||||
require('dotenv').config({ quiet: true });
|
require('dotenv').config({ quiet: true });
|
||||||
|
|
||||||
@ -5,8 +7,10 @@ const path = require('path');
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { createBoardService } = require('./boardService');
|
const { createBoardService } = require('./boardService');
|
||||||
const { createRoutes } = require('./routes');
|
const { createRoutes } = require('./routes');
|
||||||
|
const { createWeatherService } = require('./weather');
|
||||||
|
const { createTokenStore } = require('./tokenStore');
|
||||||
|
|
||||||
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env;
|
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE, ADMIN_KEY } = process.env;
|
||||||
const PORT = process.env.PORT || 3000; // default 3000
|
const PORT = process.env.PORT || 3000; // default 3000
|
||||||
|
|
||||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||||
@ -14,12 +18,26 @@ if (!CLIENT_ID || !CLIENT_SECRET) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const technicians = (TECHNICIAN_NAMES ? TECHNICIAN_NAMES.split(',') : []).map(t => t.trim()).filter(Boolean);
|
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
|
||||||
const statuses = (STATUS_NAMES ? STATUS_NAMES.split(',') : []).map(s => s.trim()).filter(Boolean);
|
|
||||||
|
const technicians = toList(TECHNICIAN_NAMES);
|
||||||
|
const statuses = toList(STATUS_NAMES);
|
||||||
const statusSet = new Set(statuses.map(s => s.toLowerCase()));
|
const statusSet = new Set(statuses.map(s => s.toLowerCase()));
|
||||||
|
// statuses that count as done; everything else shows up in the workload table
|
||||||
|
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
|
||||||
|
// statuses the unassigned counter ignores, to match the ManageEngine view
|
||||||
|
const unassignedExcludedStatuses = process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
|
||||||
|
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
|
||||||
|
: closedStatuses;
|
||||||
|
// restricts every query to these sites/groups (the region); empty means no restriction
|
||||||
|
const scope = { sites: toList(SITE_NAMES), groups: toList(GROUP_NAMES) };
|
||||||
|
|
||||||
const WINDOW_MINUTES = 10; // how far back the server looks in minutes
|
const 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 CHECK_MINUTES = 1; // how often the server checks for new tickets in minutes
|
||||||
|
const WORKLOAD_MINUTES = Number(process.env.WORKLOAD_MINUTES) || 5; // how often the status table is recounted in minutes
|
||||||
|
|
||||||
|
// the saved Zoho sign-in; holds a refresh token, so keep it out of git and off shared disks
|
||||||
|
const TOKEN_FILE = process.env.TOKEN_FILE || path.join(__dirname, '..', 'data', 'token.json');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@ -27,15 +45,29 @@ const app = express();
|
|||||||
const boardService = createBoardService({
|
const boardService = createBoardService({
|
||||||
clientId: CLIENT_ID,
|
clientId: CLIENT_ID,
|
||||||
clientSecret: CLIENT_SECRET,
|
clientSecret: CLIENT_SECRET,
|
||||||
|
tokenStore: createTokenStore(TOKEN_FILE),
|
||||||
technicians,
|
technicians,
|
||||||
statusSet,
|
statusSet,
|
||||||
|
closedStatuses,
|
||||||
|
unassignedExcludedStatuses,
|
||||||
|
scope,
|
||||||
windowMinutes: WINDOW_MINUTES,
|
windowMinutes: WINDOW_MINUTES,
|
||||||
checkMinutes: CHECK_MINUTES
|
checkMinutes: CHECK_MINUTES,
|
||||||
|
workloadMinutes: WORKLOAD_MINUTES
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
app.use('/api', createRoutes(boardService));
|
// weather widget is optional; it stays hidden unless both values are set
|
||||||
|
const weatherService = createWeatherService({ apiKey: OWM_API_KEY, zipCode: OWM_ZIP_CODE });
|
||||||
|
|
||||||
|
app.use('/api', createRoutes(boardService, weatherService, { adminKey: ADMIN_KEY }));
|
||||||
|
|
||||||
|
if (!ADMIN_KEY) console.log('ADMIN_KEY not set: Connect and Refresh only work from this machine');
|
||||||
|
|
||||||
|
boardService.resume().catch(err => {
|
||||||
|
boardService.failConnection(err);
|
||||||
|
});
|
||||||
|
|
||||||
//starts listening on port specified
|
//starts listening on port specified
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|||||||
260
src/tickets.js
260
src/tickets.js
@ -1,5 +1,9 @@
|
|||||||
const axios = require('axios');
|
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) {
|
function toMillis(completedTime) {
|
||||||
if (!completedTime) return null;
|
if (!completedTime) return null;
|
||||||
const raw = typeof completedTime === 'object' ? completedTime.value : completedTime;
|
const raw = typeof completedTime === 'object' ? completedTime.value : completedTime;
|
||||||
@ -14,14 +18,92 @@ function getRelevantTicketTime(ticket) {
|
|||||||
|| null;
|
|| null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, windowMinutes }) {
|
function describeError(err) {
|
||||||
|
const detail = err.response?.data;
|
||||||
|
console.error('Error: ', detail || err.message);
|
||||||
|
return detail?.response_status?.messages?.[0]?.message
|
||||||
|
|| (typeof detail === 'string' ? detail : null)
|
||||||
|
|| err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// joins search criteria with AND, skipping any that are null
|
||||||
|
function allOf(...criteria) {
|
||||||
|
return criteria.flat().filter(Boolean).map((c, i) => (i ? { ...c, logical_operator: 'AND' } : c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// limits a query to the configured sites/groups (the region)
|
||||||
|
function scopeCriteria(scope) {
|
||||||
|
return [
|
||||||
|
scope.sites.length ? { field: 'site.name', condition: 'is', values: scope.sites } : null,
|
||||||
|
scope.groups.length ? { field: 'group.name', condition: 'is', values: scope.groups } : null
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// same check done locally, in case the API ever returns something outside the region
|
||||||
|
function inScope(ticket, scope) {
|
||||||
|
const matches = (list, name) => !list.length
|
||||||
|
|| (!!name && list.some(item => item.toLowerCase() === name.toLowerCase()));
|
||||||
|
return matches(scope.sites, ticket.site?.name) && matches(scope.groups, ticket.group?.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRequests(accessToken, listInfo) {
|
||||||
|
const response = await axios.get(API_URL, {
|
||||||
|
params: { input_data: JSON.stringify({ list_info: listInfo }) },
|
||||||
|
headers: {
|
||||||
|
Authorization: `Zoho-oauthtoken ${accessToken}`,
|
||||||
|
Accept: 'application/vnd.manageengine.sdp.v3+json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.data || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllRequests(accessToken, listInfo, label) {
|
||||||
|
const byId = new Map();
|
||||||
|
let totalCount = null;
|
||||||
|
let pages = 0;
|
||||||
|
let duplicates = 0;
|
||||||
|
let truncated = true;
|
||||||
|
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
const data = await getRequests(accessToken, {
|
||||||
|
...listInfo,
|
||||||
|
row_count: PAGE_SIZE,
|
||||||
|
start_index: page * PAGE_SIZE + 1,
|
||||||
|
get_total_count: page === 0
|
||||||
|
});
|
||||||
|
pages++;
|
||||||
|
if (page === 0) totalCount = data.list_info?.total_count ?? null;
|
||||||
|
|
||||||
|
// dedupe by id so a page that repeats (or shifts as tickets change) can't double count
|
||||||
|
let added = 0;
|
||||||
|
for (const ticket of data.requests || []) {
|
||||||
|
const id = String(ticket.id ?? ticket.display_id);
|
||||||
|
if (byId.has(id)) duplicates++;
|
||||||
|
else { byId.set(id, ticket); added++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.list_info?.has_more_rows) { truncated = false; break; }
|
||||||
|
// more matches than we could ever page through almost always means the API ignored our
|
||||||
|
// search criteria; don't spend 19 more calls downloading the wrong data
|
||||||
|
if (totalCount > PAGE_SIZE * MAX_PAGES) break;
|
||||||
|
// a full page of repeats means start_index isn't moving us forward; stop rather than loop
|
||||||
|
if (!added) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = [...byId.values()];
|
||||||
|
if (truncated || duplicates) {
|
||||||
|
console.warn(`[${label}] pages=${pages} unique=${requests.length} duplicates=${duplicates} total_count=${totalCount} truncated=${truncated}`);
|
||||||
|
}
|
||||||
|
return { requests, truncated, totalCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, scope, windowMinutes }) {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const windowStart = Date.now() - windowMinutes * 60 * 1000;
|
const windowStart = Date.now() - windowMinutes * 60 * 1000;
|
||||||
|
|
||||||
const listInfo = {
|
const data = await getRequests(accessToken, {
|
||||||
list_info: {
|
|
||||||
row_count: 200,
|
row_count: 200,
|
||||||
sort_field: 'last_updated_time',
|
sort_field: 'last_updated_time',
|
||||||
sort_order: 'desc',
|
sort_order: 'desc',
|
||||||
@ -32,29 +114,25 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
|
|||||||
'completed_time',
|
'completed_time',
|
||||||
'resolved_time',
|
'resolved_time',
|
||||||
'last_updated_time',
|
'last_updated_time',
|
||||||
'technician'
|
'technician',
|
||||||
|
'site',
|
||||||
|
'group'
|
||||||
],
|
],
|
||||||
search_criteria: [
|
search_criteria: allOf(
|
||||||
{
|
{
|
||||||
field: 'last_updated_time',
|
field: 'last_updated_time',
|
||||||
condition: 'greater_than',
|
condition: 'greater than',
|
||||||
value: windowStart.toString()
|
value: windowStart.toString()
|
||||||
}
|
},
|
||||||
]
|
scopeCriteria(scope)
|
||||||
}
|
)
|
||||||
};
|
|
||||||
|
|
||||||
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 || [];
|
const found = data.requests || [];
|
||||||
|
|
||||||
for (const ticket of found) {
|
for (const ticket of found) {
|
||||||
|
if (!inScope(ticket, scope)) continue;
|
||||||
|
|
||||||
const statusName = ticket.status?.name || '';
|
const statusName = ticket.status?.name || '';
|
||||||
const statusInternalName = ticket.status?.internal_name || '';
|
const statusInternalName = ticket.status?.internal_name || '';
|
||||||
const statusMatches = !statusSet.size
|
const statusMatches = !statusSet.size
|
||||||
@ -90,15 +168,149 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
|
|||||||
state.lastCheck = Date.now();
|
state.lastCheck = Date.now();
|
||||||
state.lastError = null;
|
state.lastError = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const detail = err.response?.data;
|
state.lastError = describeError(err);
|
||||||
state.lastError = detail?.response_status?.messages?.[0]?.message
|
|
||||||
|| (typeof detail === 'string' ? detail : null)
|
|
||||||
|| err.message;
|
|
||||||
state.lastCheck = Date.now();
|
state.lastCheck = Date.now();
|
||||||
console.error('Error: ', detail || err.message);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts every open ticket by technician and status, plus how many each
|
||||||
|
// technician has closed since midnight.
|
||||||
|
async function fetchWorkload({ accessToken, workload, technicians, closedStatuses, scope }) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
|
||||||
|
const startOfDay = new Date();
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const closedLower = new Set(closedStatuses.map(s => s.toLowerCase()));
|
||||||
|
const byLowerName = new Map(technicians.map(t => [t.toLowerCase(), t]));
|
||||||
|
const techCriterion = technicians.length
|
||||||
|
? { field: 'technician.name', condition: 'is', values: technicians }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// maps a ticket to the row it's counted under, or null to skip it
|
||||||
|
function rowFor(ticket) {
|
||||||
|
if (!inScope(ticket, scope)) return null;
|
||||||
|
const name = ticket.technician?.name;
|
||||||
|
if (!technicians.length) return name || 'Unassigned';
|
||||||
|
return name ? byLowerName.get(name.toLowerCase()) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const open = await getAllRequests(accessToken, {
|
||||||
|
sort_field: 'created_time',
|
||||||
|
sort_order: 'desc',
|
||||||
|
fields_required: ['display_id', 'status', 'technician', 'site', 'group'],
|
||||||
|
search_criteria: allOf(
|
||||||
|
{ field: 'status.name', condition: 'is not', values: closedStatuses },
|
||||||
|
techCriterion,
|
||||||
|
scopeCriteria(scope)
|
||||||
|
)
|
||||||
|
}, 'open tickets');
|
||||||
|
|
||||||
|
const closed = await getAllRequests(accessToken, {
|
||||||
|
sort_field: 'last_updated_time',
|
||||||
|
sort_order: 'desc',
|
||||||
|
fields_required: ['display_id', 'status', 'technician', 'site', 'group', 'completed_time', 'resolved_time', 'last_updated_time'],
|
||||||
|
search_criteria: allOf(
|
||||||
|
{ field: 'last_updated_time', condition: 'greater than', value: startOfDay.getTime().toString() },
|
||||||
|
{ field: 'status.name', condition: 'is', values: closedStatuses },
|
||||||
|
techCriterion,
|
||||||
|
scopeCriteria(scope)
|
||||||
|
)
|
||||||
|
}, 'closed today');
|
||||||
|
|
||||||
|
const rows = new Map(technicians.map(t => [t, { name: t, counts: {}, open: 0, closedToday: 0 }]));
|
||||||
|
const row = name => {
|
||||||
|
if (!rows.has(name)) rows.set(name, { name, counts: {}, open: 0, closedToday: 0 });
|
||||||
|
return rows.get(name);
|
||||||
|
};
|
||||||
|
const statuses = new Map();
|
||||||
|
|
||||||
|
for (const ticket of open.requests) {
|
||||||
|
const status = ticket.status?.name;
|
||||||
|
if (!status || closedLower.has(status.toLowerCase())) continue;
|
||||||
|
const tech = rowFor(ticket);
|
||||||
|
if (!tech) continue;
|
||||||
|
|
||||||
|
const r = row(tech);
|
||||||
|
r.counts[status] = (r.counts[status] || 0) + 1;
|
||||||
|
r.open++;
|
||||||
|
|
||||||
|
if (!statuses.has(status)) {
|
||||||
|
const color = /^#[0-9a-f]{3,8}$/i.test(ticket.status.color || '') ? ticket.status.color : null;
|
||||||
|
statuses.set(status, { name: status, color, total: 0 });
|
||||||
|
}
|
||||||
|
statuses.get(status).total++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ticket of closed.requests) {
|
||||||
|
const closedAt = getRelevantTicketTime(ticket);
|
||||||
|
if (!closedAt || closedAt < startOfDay.getTime()) continue;
|
||||||
|
const tech = rowFor(ticket);
|
||||||
|
if (tech) row(tech).closedToday++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open first, then busiest statuses
|
||||||
|
workload.statuses = [...statuses.values()].sort((a, b) =>
|
||||||
|
(b.name === 'Open') - (a.name === 'Open') || b.total - a.total || a.name.localeCompare(b.name));
|
||||||
|
workload.technicians = [...rows.values()];
|
||||||
|
if (!technicians.length) workload.technicians.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
// which query hit the page cap, and how many tickets SDP says matched, so the page can explain it
|
||||||
|
const cutOff = (label, result) => result.truncated
|
||||||
|
&& `${label} (${result.requests.length} of ${result.totalCount ?? '?'})`;
|
||||||
|
workload.truncated = [cutOff('open tickets', open), cutOff('closed today', closed)]
|
||||||
|
.filter(Boolean).join(', ') || false;
|
||||||
|
workload.updatedAt = Date.now();
|
||||||
|
workload.error = null;
|
||||||
|
} catch (err) {
|
||||||
|
workload.error = describeError(err);
|
||||||
|
workload.updatedAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the "Central Region Unassigned" view: in-scope, not excluded, no technician.
|
||||||
|
async function fetchUnassigned({ accessToken, unassigned, excludedStatuses, scope }) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
|
||||||
|
const excludedLower = new Set(excludedStatuses.map(s => s.toLowerCase()));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAllRequests(accessToken, {
|
||||||
|
sort_field: 'created_time',
|
||||||
|
sort_order: 'desc',
|
||||||
|
fields_required: ['display_id', 'subject', 'status', 'technician', 'site', 'group', 'created_time'],
|
||||||
|
search_criteria: allOf(
|
||||||
|
scopeCriteria(scope),
|
||||||
|
excludedStatuses.length ? { field: 'status.name', condition: 'is not', values: excludedStatuses } : null,
|
||||||
|
// "is empty": the API documents "technician is not empty" as condition 'is not' with no value
|
||||||
|
{ field: 'technician', condition: 'is' }
|
||||||
|
)
|
||||||
|
}, 'unassigned');
|
||||||
|
|
||||||
|
unassigned.tickets = result.requests
|
||||||
|
.filter(t => !t.technician && inScope(t, scope) && !excludedLower.has((t.status?.name || '').toLowerCase()))
|
||||||
|
.map(t => ({
|
||||||
|
id: t.display_id,
|
||||||
|
subject: t.subject,
|
||||||
|
status: t.status?.name || null,
|
||||||
|
site: t.site?.name || null,
|
||||||
|
group: t.group?.name || null,
|
||||||
|
created: t.created_time?.display_value || null
|
||||||
|
}));
|
||||||
|
unassigned.count = unassigned.tickets.length;
|
||||||
|
// how many the API returned that we threw out locally; non-zero means the criteria are off
|
||||||
|
unassigned.discarded = result.requests.length - unassigned.count;
|
||||||
|
unassigned.truncated = result.truncated;
|
||||||
|
unassigned.updatedAt = Date.now();
|
||||||
|
unassigned.error = null;
|
||||||
|
} catch (err) {
|
||||||
|
unassigned.error = describeError(err);
|
||||||
|
unassigned.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fetchClosedTickets
|
fetchClosedTickets,
|
||||||
|
fetchWorkload,
|
||||||
|
fetchUnassigned
|
||||||
};
|
};
|
||||||
37
src/tokenStore.js
Normal file
37
src/tokenStore.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// keeps the Zoho tokens on disk so a restart doesn't need a new sign-in
|
||||||
|
function createTokenStore(filePath) {
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
|
return saved && saved.refreshToken ? saved : null;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') console.error('Could not read saved token:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(token) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
// write then rename, so a crash mid-write can't leave a half-written file behind
|
||||||
|
const tmp = filePath + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify({ ...token, savedAt: Date.now() }, null, 2), { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') console.error('Could not remove saved token:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { load, save, clear, filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createTokenStore
|
||||||
|
};
|
||||||
107
src/weather.js
Normal file
107
src/weather.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_URL = 'https://api.openweathermap.org/data/2.5';
|
||||||
|
const CACHE_MINUTES = 10; // OpenWeatherMap only updates about every 10 minutes anyway
|
||||||
|
const FORECAST_DAYS = 5;
|
||||||
|
|
||||||
|
// only pass through icon codes like "10d" so the page can build the image URL safely
|
||||||
|
function iconCode(icon) {
|
||||||
|
return /^\d\d[dn]$/.test(icon || '') ? icon : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the 5 day forecast comes in 3 hour slots; roll them up into one entry per local day
|
||||||
|
function dailyForecast(forecast) {
|
||||||
|
const offset = (forecast.city?.timezone || 0) * 1000;
|
||||||
|
const days = new Map();
|
||||||
|
|
||||||
|
for (const slot of forecast.list || []) {
|
||||||
|
const local = new Date(slot.dt * 1000 + offset);
|
||||||
|
const key = local.toISOString().slice(0, 10);
|
||||||
|
if (!days.has(key)) days.set(key, { date: key, low: Infinity, high: -Infinity, pop: 0, noon: null });
|
||||||
|
|
||||||
|
const day = days.get(key);
|
||||||
|
day.low = Math.min(day.low, slot.main.temp_min);
|
||||||
|
day.high = Math.max(day.high, slot.main.temp_max);
|
||||||
|
day.pop = Math.max(day.pop, slot.pop || 0);
|
||||||
|
|
||||||
|
// the slot nearest midday best represents the day's conditions
|
||||||
|
const distance = Math.abs(local.getUTCHours() - 12);
|
||||||
|
if (!day.noon || distance < day.noon.distance) day.noon = { distance, weather: slot.weather?.[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...days.values()].slice(0, FORECAST_DAYS).map(day => ({
|
||||||
|
date: day.date,
|
||||||
|
low: Math.round(day.low),
|
||||||
|
high: Math.round(day.high),
|
||||||
|
pop: Math.round(day.pop * 100),
|
||||||
|
condition: day.noon?.weather?.main || null,
|
||||||
|
icon: iconCode(day.noon?.weather?.icon)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWeatherService({ apiKey, zipCode }) {
|
||||||
|
const enabled = !!(apiKey && zipCode);
|
||||||
|
// US zip by default; "12345,ca" style values pass through untouched
|
||||||
|
const zip = zipCode && zipCode.includes(',') ? zipCode : `${zipCode},us`;
|
||||||
|
|
||||||
|
let cache = null;
|
||||||
|
let fetchedAt = 0;
|
||||||
|
let pending = null;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
async function fetchWeather() {
|
||||||
|
const params = { zip, units: 'imperial', appid: apiKey };
|
||||||
|
const [current, forecast] = await Promise.all([
|
||||||
|
axios.get(`${API_URL}/weather`, { params }),
|
||||||
|
axios.get(`${API_URL}/forecast`, { params })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const c = current.data;
|
||||||
|
return {
|
||||||
|
place: c.name,
|
||||||
|
updatedAt: c.dt * 1000,
|
||||||
|
temp: Math.round(c.main.temp),
|
||||||
|
feelsLike: Math.round(c.main.feels_like),
|
||||||
|
high: Math.round(c.main.temp_max),
|
||||||
|
low: Math.round(c.main.temp_min),
|
||||||
|
humidity: c.main.humidity,
|
||||||
|
wind: Math.round(c.wind?.speed || 0),
|
||||||
|
condition: c.weather?.[0]?.main || null,
|
||||||
|
description: c.weather?.[0]?.description || null,
|
||||||
|
icon: iconCode(c.weather?.[0]?.icon),
|
||||||
|
sunrise: c.sys?.sunrise ? c.sys.sunrise * 1000 : null,
|
||||||
|
sunset: c.sys?.sunset ? c.sys.sunset * 1000 : null,
|
||||||
|
forecast: dailyForecast(forecast.data)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWeather() {
|
||||||
|
if (!enabled) return { enabled: false };
|
||||||
|
|
||||||
|
if (Date.now() - fetchedAt > CACHE_MINUTES * 60 * 1000) {
|
||||||
|
// share one request between callers that arrive while it's in flight
|
||||||
|
pending = pending || fetchWeather()
|
||||||
|
.then(data => {
|
||||||
|
cache = data;
|
||||||
|
lastError = null;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
lastError = err.response?.data?.message || err.message;
|
||||||
|
console.error('Weather error: ', lastError);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
fetchedAt = Date.now();
|
||||||
|
pending = null;
|
||||||
|
});
|
||||||
|
await pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enabled: true, error: lastError, ...cache };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getWeather };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createWeatherService
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user