From 8b38d6a9c999527a599db06b98273718a07b93c6 Mon Sep 17 00:00:00 2001 From: Christoph Gasser Date: Mon, 20 Apr 2026 17:10:57 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Zabbix=20Dashboa?= =?UTF-8?q?rd=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 9 + .gitignore | 3 + CLAUDE.md | 98 +++++ Dockerfile | 7 + description.txt | 6 + docker-compose.yml | 12 + package-lock.json | 954 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 15 + public/app.js | 417 ++++++++++++++++++++ public/index.html | 76 ++++ public/style.css | 624 +++++++++++++++++++++++++++++ readme.txt | 2 + server.js | 301 ++++++++++++++ 13 files changed, 2524 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 description.txt create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 readme.txt create mode 100644 server.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fd15d97 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +ZABBIX_URL=https://monitor.stranto.com +ZABBIX_TOKEN=your_api_token_here +PORT=3000 + +# Tag name used on hosts to identify the country (value e.g. AT / SK) +COUNTRY_TAG=country + +# Tag value that marks a host as a KFC restaurant +CUSTOMER_TAG_VALUE=QWE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7cce65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ea021a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm install # install dependencies +npm start # run production server (port 3000) +npm run dev # run with --watch (auto-restart on file changes) +``` + +There are no tests or linters configured. + +Docker: +```bash +docker-compose up -d --build +``` + +## Architecture + +Single-process Node.js app. `server.js` acts as both an API proxy (keeping the Zabbix token server-side) and a static file host for the vanilla JS frontend in `public/`. + +``` +server.js Express backend — Zabbix JSON-RPC proxy + REST API +public/ + index.html Shell: header, view toggle, countries layout container, modal + style.css Light theme, CSS variables for hex sizing, hex grid layout + app.js All frontend logic — fetch, render, interactions +.env Runtime config (gitignored) +docker-compose.yml Reads ZABBIX_TOKEN from env or .env +``` + +## Config (env vars) + +| Variable | Default | Purpose | +|---|---|---| +| `ZABBIX_URL` | `https://monitor.stranto.com` | Zabbix instance | +| `ZABBIX_TOKEN` | — | API bearer token (required) | +| `CUSTOMER_TAG_VALUE` | `QWE` | Zabbix host tag `customer=` that identifies KFC hosts | +| `COUNTRY_TAG` | `country` | Zabbix host tag name used for country grouping | +| `PORT` | `3000` | HTTP port | + +## Zabbix API conventions + +- Auth: `Authorization: Bearer ` header (Zabbix 6.4+ style — NOT the legacy `auth` body field) +- All hosts are scoped by tag `customer=QWE` (operator `1` = equals) +- Host groups use `selectHostGroups` / `host.hostgroups` — NOT the deprecated `selectGroups` / `host.groups` +- Countries come from a `country` host tag (e.g. `AT`, `SK`) +- Restaurants group by `location` host tag; device types group by host group, keyed as `groupid__country` +- Severity 0 (Not classified) and 1 (Information) are treated as OK throughout +- Acknowledged problems are excluded via `withLastEventUnacknowledged: true` +- `expandDescription: true` on `trigger.get` resolves `{HOST.NAME}` and other macros in descriptions + +## Backend API endpoints + +| Endpoint | Returns | +|---|---| +| `GET /api/restaurants` | Array of `{ location, country, hostCount, problemCount, severity, problems[] }` | +| `GET /api/devices` | Same shape but grouped by host group; `name` instead of `location` | +| `GET /api/stats` | `{ [countryCode]: { hostCount, itemCount, triggerCount } }` — fetched in parallel with grid data | +| `GET /api/detail?type=&id=` | Per-host problem detail for modal | +| `GET /api/config` | `{ zabbixUrl, customerTagValue }` | +| `GET /api/health` | Zabbix connectivity check | + +Each `problems[]` entry: `{ description, priority, lastchange (unix seconds), hostName }`, sorted by severity desc then time desc. + +## Frontend data flow + +1. Boot: fetch `/api/config`, then fetch grid data + `/api/stats` in parallel +2. Items are grouped by `country` field and rendered as equal-width `.country-col` columns +3. Each column renders: flag + country header → hex grid → problem list (locations with active problems) +4. Single click → detail modal (`/api/detail`); double click → Zabbix Problems page in new tab (240ms timer separates the two) +5. Auto-refresh every 30 seconds + +## Hex grid geometry + +For equal gaps on all 6 sides, the hex dimensions must satisfy `--hex-w = --hex-h × sin(60°) ≈ --hex-h × 0.866`. The row vertical overlap is: + +```css +margin-top: calc(var(--hex-h) * -0.25 + var(--hex-gap) * 0.866); +``` + +Changing `--hex-gap` without updating `margin-top` will make diagonal gaps unequal. The even-row offset (`margin-left` on `.hex-row.offset`) is always `(hex-w + hex-gap) / 2`. + +## Zabbix Problems URL format (7.x) + +Parameters use **no** `filter_` prefix. Key params: `show=1` (recent), `evaltype=0`, `tags[N][tag/value/operator]`, `groupids[]`, `severities[]=2..5`. The legacy `filter_show`, `filter_tags`, `filter_set` format does not work in Zabbix 7.x. + +## Severity mapping + +| Zabbix priority | Label | Colour | +|---|---|---| +| -1 (no problems / ack / info) | OK | green `#2da44e` | +| 2 | Warning | amber `#c69026` | +| 3 | Average | orange `#e16f24` | +| 4 | High | red `#d1242f` | +| 5 | Disaster | purple `#8250df` (pulses) | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b9ec6a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --production +COPY . . +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/description.txt b/description.txt new file mode 100644 index 0000000..c6394dc --- /dev/null +++ b/description.txt @@ -0,0 +1,6 @@ +I a simple well arranged and clean Dashboard Website. It connects to my Zabbix 7.4.9 instance via API. +I have around 40 KFC Restaurants that I Need to monitor. First distinction is by Country: Austria and Slovakia . +The Dashboard Needs two views: per Restaurant and per device type. +Per Restaurant: All hosts with a specific "customer" tag (tag value is "QWE"). Item identifier is tag "location". +Per device type: this is based on the host group. +Graphical representation of one item (restaurant/location and device type) is with a hexagon, circle, etc. Feel free to make a suggestion. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8f03a9b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + dashboard: + build: . + ports: + - "3000:3000" + environment: + - PORT=3000 + - ZABBIX_URL=https://monitor.stranto.com + - ZABBIX_TOKEN=${ZABBIX_TOKEN} + - CUSTOMER_TAG_VALUE=QWE + - COUNTRY_TAG=country + restart: unless-stopped diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..93c38ea --- /dev/null +++ b/package-lock.json @@ -0,0 +1,954 @@ +{ + "name": "zabbix-kfc-dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zabbix-kfc-dashboard", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.0", + "dotenv": "^16.4.0", + "express": "^4.19.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", + "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb72c7c --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "zabbix-kfc-dashboard", + "version": "1.0.0", + "description": "KFC Restaurant Monitoring Dashboard for Zabbix", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "axios": "^1.7.0", + "dotenv": "^16.4.0", + "express": "^4.19.2" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..dc491af --- /dev/null +++ b/public/app.js @@ -0,0 +1,417 @@ +/* ── Severity config ─────────────────────────────────────────────────────── */ +const SEV = { + '-1': { label: 'OK', color: '#2da44e' }, + '2': { label: 'Warning', color: '#c69026' }, + '3': { label: 'Average', color: '#e16f24' }, + '4': { label: 'High', color: '#d1242f' }, + '5': { label: 'Disaster', color: '#8250df' }, +}; + +const COUNTRY_NAMES = { + AT: 'Austria', AUT: 'Austria', AUSTRIA: 'Austria', + SK: 'Slovakia', SVK: 'Slovakia', SLOVAKIA: 'Slovakia', +}; + +const COUNTRY_FLAGS = { + AT: 'at', AUT: 'at', AUSTRIA: 'at', + SK: 'sk', SVK: 'sk', SLOVAKIA: 'sk', +}; + +function countryLabel(code) { + return COUNTRY_NAMES[code?.toUpperCase()] || code || 'Unknown'; +} + +function countryFlagUrl(code) { + const fc = COUNTRY_FLAGS[code?.toUpperCase()]; + return fc ? `https://flagcdn.com/32x24/${fc}.png` : null; +} + +function sevInfo(sev) { + return SEV[String(sev)] || SEV['-1']; +} + +/* ── State ───────────────────────────────────────────────────────────────── */ +let currentView = 'restaurants'; +let refreshTimer = null; +let zabbixUrl = ''; +let customerTagValue = 'QWE'; +const REFRESH_MS = 30_000; + +/* ── Boot ────────────────────────────────────────────────────────────────── */ +document.addEventListener('DOMContentLoaded', async () => { + try { + const cfg = await apiFetch('/api/config'); + zabbixUrl = cfg.zabbixUrl.replace(/\/$/, ''); + customerTagValue = cfg.customerTagValue || 'QWE'; + } catch (_) {} + initControls(); + loadData(); + scheduleRefresh(); +}); + +/* ── Controls ────────────────────────────────────────────────────────────── */ +function initControls() { + document.querySelectorAll('.toggle-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentView = btn.dataset.view; + loadData(); + }); + }); + + document.getElementById('refresh-btn').addEventListener('click', () => { + loadData(); + scheduleRefresh(); + }); + + document.getElementById('modal-close-btn').addEventListener('click', closeModal); + document.getElementById('modal').addEventListener('click', e => { + if (e.target === e.currentTarget) closeModal(); + }); + document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeModal(); + }); +} + +/* ── Data loading ────────────────────────────────────────────────────────── */ +async function loadData() { + setRefreshSpinner(true); + showPageLoading(); + + try { + const endpoint = currentView === 'restaurants' ? '/api/restaurants' : '/api/devices'; + const [items, stats] = await Promise.all([ + apiFetch(endpoint), + apiFetch('/api/stats').catch(() => ({})), + ]); + renderCountryColumns(items, stats); + updateSummary(items); + setLastUpdated(new Date()); + } catch (e) { + showPageError(e.message); + } finally { + setRefreshSpinner(false); + } +} + +function scheduleRefresh() { + clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => { loadData(); scheduleRefresh(); }, REFRESH_MS); +} + +/* ── Country column layout ───────────────────────────────────────────────── */ +function renderCountryColumns(items, stats = {}) { + // Group by country + const byCountry = {}; + for (const item of items) { + const key = item.country || ''; + if (!byCountry[key]) byCountry[key] = []; + byCountry[key].push(item); + } + + const countries = Object.keys(byCountry).sort(); + const wrap = document.getElementById('countries-wrap'); + wrap.innerHTML = ''; + + if (countries.length === 0) { + wrap.innerHTML = '

No items found.

'; + return; + } + + countries.forEach(country => { + const col = document.createElement('div'); + col.className = 'country-col'; + + const colItems = byCountry[country]; + const problems = colItems.filter(i => i.severity >= 2).length; + + const flagUrl = countryFlagUrl(country); + const flagImg = flagUrl + ? `` + : ''; + + const s = stats[country]; + const statsHtml = s + ? `${s.hostCount.toLocaleString()} hosts · ${s.itemCount.toLocaleString()} items · ${s.triggerCount.toLocaleString()} triggers` + : ''; + + const hdr = document.createElement('div'); + hdr.className = 'country-col-header'; + hdr.innerHTML = ` + ${flagImg}${esc(countryLabel(country))} + ${statsHtml} + `; + + const body = document.createElement('div'); + body.className = 'country-col-body'; + + const grid = document.createElement('div'); + grid.className = 'hex-grid'; + body.appendChild(grid); + col.appendChild(hdr); + col.appendChild(body); + wrap.appendChild(col); + + // Compute perRow after layout is in DOM + requestAnimationFrame(() => { + const hexW = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-w')); + const hexGap = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-gap')); + const colW = body.clientWidth - 40; // subtract padding + const perRow = Math.max(2, Math.floor(colW / (hexW + hexGap))); + renderHexGrid(grid, colItems, perRow); + renderProblemList(body, colItems); + }); + }); +} + +function renderHexGrid(container, items, perRow) { + const rows = chunk(items, perRow); + const wrap = document.createElement('div'); + wrap.className = 'hex-col-wrap'; + + rows.forEach((row, ri) => { + const rowEl = document.createElement('div'); + rowEl.className = 'hex-row' + (ri % 2 === 1 ? ' offset' : ''); + row.forEach(item => rowEl.appendChild(buildHex(item))); + wrap.appendChild(rowEl); + }); + + container.innerHTML = ''; + container.appendChild(wrap); +} + +/* ── Hex builder ─────────────────────────────────────────────────────────── */ +function buildHex(item) { + const sev = item.severity; + const info = sevInfo(sev); + const label = item.location || item.name || '—'; + const id = item.location || item.groupid || ''; + const type = item.location ? 'restaurant' : 'device'; + const issues = item.problemCount || 0; + const hosts = item.hostCount || 0; + + const el = document.createElement('div'); + el.className = `hex-item sev-${sev}`; + el.style.background = info.color; + el.title = `${label}\n${hosts} host(s) · ${issues} issue(s) · ${info.label}\n\nClick: details Double-click: open in Zabbix`; + + const labelEl = document.createElement('span'); + labelEl.className = 'hex-label'; + labelEl.textContent = label; + el.appendChild(labelEl); + + const hostsEl = document.createElement('span'); + hostsEl.className = 'hex-hosts'; + hostsEl.textContent = `${hosts} host${hosts !== 1 ? 's' : ''}`; + el.appendChild(hostsEl); + + if (issues > 0) { + const countEl = document.createElement('span'); + countEl.className = 'hex-count'; + countEl.textContent = `${issues} issue${issues !== 1 ? 's' : ''}`; + el.appendChild(countEl); + } + + // Use timer to distinguish single click (modal) from double click (Zabbix) + let clickTimer = null; + el.addEventListener('click', () => { + clearTimeout(clickTimer); + clickTimer = setTimeout(() => openModal(type, id, label, String(sev)), 240); + }); + el.addEventListener('dblclick', () => { + clearTimeout(clickTimer); + window.open(buildZabbixProblemsUrl(type, id), '_blank'); + }); + return el; +} + +/* ── Modal ───────────────────────────────────────────────────────────────── */ +async function openModal(type, id, label, sev) { + const modal = document.getElementById('modal'); + const info = sevInfo(sev); + + document.getElementById('modal-title').textContent = label; + document.getElementById('modal-sev-dot').style.background = info.color; + document.getElementById('modal-body').innerHTML = + ''; + modal.style.display = 'flex'; + + try { + const items = await apiFetch( + `/api/detail?type=${encodeURIComponent(type)}&id=${encodeURIComponent(id)}` + ); + renderModalBody(document.getElementById('modal-body'), items); + } catch (e) { + document.getElementById('modal-body').innerHTML = + `
Failed to load details: ${esc(e.message)}
`; + } +} + +function renderModalBody(body, items) { + if (!items || items.length === 0) { + body.innerHTML = '

No hosts found.

'; + return; + } + + body.innerHTML = items.map(host => { + const info = sevInfo(String(host.severity)); + + const problemsHtml = host.problems.length === 0 + ? `

✓ No active problems

` + : `
${host.problems.map(p => { + const pi = sevInfo(String(p.priority)); + return ` +
+ +
+
${esc(p.description)}
+
${esc(p.lastchange)}
+
+
`; + }).join('')}
`; + + return ` +
+
+ ${esc(host.name)} + ${esc(info.label)} +
+ ${problemsHtml} +
`; + }).join(''); +} + +/* ── Problem list (below hex grid) ──────────────────────────────────────── */ +function renderProblemList(container, items) { + const withProblems = items.filter(i => i.problems && i.problems.length > 0); + if (withProblems.length === 0) return; + + const section = document.createElement('div'); + section.className = 'problems-section'; + + const title = document.createElement('div'); + title.className = 'problems-section-title'; + title.textContent = 'Active Problems'; + section.appendChild(title); + + for (const item of withProblems) { + const label = item.location || item.name || '—'; + + const group = document.createElement('div'); + group.className = 'problem-group'; + + const groupHdr = document.createElement('div'); + groupHdr.className = 'problem-group-header'; + groupHdr.innerHTML = + `${esc(label)}` + + `${item.problems.length}`; + group.appendChild(groupHdr); + + for (const p of item.problems) { + const info = sevInfo(String(p.priority)); + const row = document.createElement('div'); + row.className = 'problem-list-row'; + row.innerHTML = + `` + + `${esc(p.hostName)}` + + `${esc(p.description)}` + + `${timeAgo(p.lastchange)}`; + group.appendChild(row); + } + + section.appendChild(group); + } + + container.appendChild(section); +} + +function timeAgo(unixSeconds) { + const diff = Math.floor(Date.now() / 1000) - unixSeconds; + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +function buildZabbixProblemsUrl(type, id) { + // Zabbix 7.x URL parameter format (no filter_ prefix) + const p = [ + 'action=problem.view', + 'show=1', // Recent problems + 'evaltype=0', // AND tag matching + // Exclude Not classified (0) and Information (1) — show Warning and above + 'severities[]=2', + 'severities[]=3', + 'severities[]=4', + 'severities[]=5', + ]; + + if (type === 'restaurant') { + p.push(`tags[0][tag]=customer`); + p.push(`tags[0][value]=${encodeURIComponent(customerTagValue)}`); + p.push(`tags[0][operator]=1`); + p.push(`tags[1][tag]=location`); + p.push(`tags[1][value]=${encodeURIComponent(id)}`); + p.push(`tags[1][operator]=1`); + } else { + p.push(`groupids[]=${encodeURIComponent(id)}`); + p.push(`tags[0][tag]=customer`); + p.push(`tags[0][value]=${encodeURIComponent(customerTagValue)}`); + p.push(`tags[0][operator]=1`); + } + + return `${zabbixUrl}/zabbix.php?${p.join('&')}`; +} + +function closeModal() { + document.getElementById('modal').style.display = 'none'; +} + +/* ── UI helpers ──────────────────────────────────────────────────────────── */ +function showPageLoading() { + document.getElementById('countries-wrap').innerHTML = + '

Loading…

'; +} + +function showPageError(msg) { + document.getElementById('countries-wrap').innerHTML = + `
Error: ${esc(msg)}
`; +} + +function setRefreshSpinner(on) { + document.getElementById('refresh-btn').classList.toggle('spinning', on); +} + +function updateSummary(items) { + const issues = items.filter(i => i.severity >= 2).length; + document.getElementById('summary').innerHTML = issues > 0 + ? `${items.length} items · ${issues} with issues` + : `${items.length} items · All OK`; +} + +function setLastUpdated(date) { + document.getElementById('last-updated').textContent = + `Updated ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`; +} + +/* ── Fetch wrapper ───────────────────────────────────────────────────────── */ +async function apiFetch(url) { + const res = await fetch(url); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`); + return data; +} + +/* ── Utils ───────────────────────────────────────────────────────────────── */ +function esc(str) { + return String(str) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function chunk(arr, size) { + const out = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..485b092 --- /dev/null +++ b/public/index.html @@ -0,0 +1,76 @@ + + + + + + KFC IT Monitor by Stranto + + + + + + + +
+
+
+ KFC IT Monitor by Stranto +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ OK + Warning + Average + High + Disaster +
+
+
+ +
+
+
+

Connecting to Zabbix…

+
+
+ + + + + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..e242980 --- /dev/null +++ b/public/style.css @@ -0,0 +1,624 @@ +/* ── Variables ─────────────────────────────────────────────────────────────── */ +:root { + --bg: #ffffff; + --surface: #f6f8fa; + --surface-2: #eef1f4; + --surface-3: #e1e4e8; + --border: #d0d7de; + --text: #1f2328; + --text-muted: #636c76; + --accent: #0969da; + --accent-dim: rgba(9,105,218,0.08); + + --c-ok: #2da44e; + --c-warning: #c69026; + --c-average: #e16f24; + --c-high: #d1242f; + --c-disaster: #8250df; + --c-unknown: #8c959f; + + --hex-w: 132px; /* = hex-h × sin(60°) = 152 × 0.866 — required for regular hexagon */ + --hex-h: 152px; + --hex-gap: 6px; +} + +/* ── Reset ─────────────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + line-height: 1.5; +} + +/* ── Header ────────────────────────────────────────────────────────────────── */ +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + height: 54px; + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 200; + gap: 16px; +} + +.header-brand { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.brand-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--c-ok); + box-shadow: 0 0 0 3px rgba(45,164,78,0.2); +} + +.brand-name { + font-weight: 700; + font-size: 0.95rem; + color: var(--text); +} + +/* View toggle */ +.view-toggle { + display: flex; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px; + gap: 2px; +} + +.toggle-btn { + padding: 5px 14px; + border: none; + background: transparent; + color: var(--text-muted); + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; + font-weight: 500; + transition: background 0.12s, color 0.12s; + white-space: nowrap; +} + +.toggle-btn.active { + background: var(--surface-3); + color: var(--text); +} + +.toggle-btn:not(.active):hover { color: var(--text); } + +/* Header meta */ +.header-meta { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.last-updated { + font-size: 0.72rem; + color: var(--text-muted); + white-space: nowrap; +} + +.refresh-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: color 0.12s, border-color 0.12s, background 0.12s; +} + +.refresh-btn:hover { color: var(--text); background: var(--surface-2); } +.refresh-btn.spinning svg { animation: spin 0.6s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Subbar ────────────────────────────────────────────────────────────────── */ +.subbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + height: 42px; + background: var(--surface); + border-bottom: 1px solid var(--border); + gap: 16px; +} + +.legend { + display: flex; + gap: 18px; + flex-wrap: wrap; +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.73rem; + color: var(--text-muted); +} + +.swatch { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} + +.swatch.ok { background: var(--c-ok); } +.swatch.warning { background: var(--c-warning); } +.swatch.average { background: var(--c-average); } +.swatch.high { background: var(--c-high); } +.swatch.disaster { background: var(--c-disaster); } + +.summary { + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +.summary strong { color: var(--text); } + +/* ── Countries split layout ────────────────────────────────────────────────── */ +.countries-wrap { + display: flex; + min-height: calc(100vh - 96px); + align-items: stretch; +} + +.country-col { + flex: 1; + min-width: 0; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.country-col:last-child { + border-right: none; +} + +.country-col-header { + padding: 14px 20px 12px; + font-size: 1rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text); + border-bottom: 1px solid var(--border); + background: var(--surface); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.country-col-count { + font-size: 0.7rem; + font-weight: 500; + color: var(--text-muted); + text-transform: none; + letter-spacing: 0; +} + +.country-col-stats { + font-size: 0.78rem; + font-weight: 400; + color: var(--text-muted); + text-transform: none; + letter-spacing: 0; + white-space: nowrap; +} + +.country-col-body { + padding: 20px; + flex: 1; + overflow-y: auto; +} + +/* ── Loading / Error ───────────────────────────────────────────────────────── */ +.page-center { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 80px 24px; + color: var(--text-muted); + font-size: 0.875rem; + width: 100%; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--surface-3); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.error-box { + background: #fff0ee; + border: 1px solid #ffa198; + color: #b91c1c; + border-radius: 8px; + padding: 14px 18px; + font-size: 0.85rem; + max-width: 600px; + margin: 20px; +} + +/* ── Hex Grid ──────────────────────────────────────────────────────────────── */ +.hex-col-wrap { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.hex-row { + display: flex; + gap: var(--hex-gap); + /* Vertical overlap for equal gaps on all 6 sides: + center-to-center-y = (hex-w + gap) × sin(60°) + margin-top = -(hex-h - center-to-center-y) = -(hex-h/4 - gap × 0.866) */ + margin-top: calc(var(--hex-h) * -0.25 + var(--hex-gap) * 0.866); +} + +.hex-row:first-child { + margin-top: 0; +} + +.hex-row.offset { + margin-left: calc(var(--hex-w) / 2 + var(--hex-gap) / 2); +} + +/* ── Hex Item ──────────────────────────────────────────────────────────────── */ +.hex-item { + width: var(--hex-w); + height: var(--hex-h); + clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + transition: filter 0.15s, transform 0.15s; + position: relative; + padding: 18px 16px; + flex-shrink: 0; + user-select: none; +} + +.hex-item:hover { + filter: brightness(1.1) saturate(1.15); + transform: scale(1.07); + z-index: 10; +} + +.hex-item:active { transform: scale(0.97); } + +.hex-label { + font-size: 1.8rem; + font-weight: 800; + color: #fff; + text-align: center; + line-height: 1.2; + word-break: break-word; + max-width: 108px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + text-overflow: ellipsis; + text-shadow: 0 1px 3px rgba(0,0,0,0.3); +} + +.hex-hosts { + font-size: 1rem; + font-weight: 500; + color: rgba(255,255,255,0.82); + margin-top: 2px; + line-height: 1; + text-shadow: 0 1px 2px rgba(0,0,0,0.2); +} + +.hex-count { + font-size: 0.68rem; + font-weight: 800; + color: rgba(255,255,255,0.95); + margin-top: 3px; + line-height: 1; + text-shadow: 0 1px 2px rgba(0,0,0,0.3); +} + +/* ── Severity colours ──────────────────────────────────────────────────────── */ +.sev--1 { background: var(--c-ok); } +.sev-2 { background: var(--c-warning); } +.sev-3 { background: var(--c-average); } +.sev-4 { background: var(--c-high); } +.sev-5 { + background: var(--c-disaster); + animation: disaster-pulse 1.8s ease-in-out infinite; +} + +@keyframes disaster-pulse { + 0%,100% { filter: brightness(1); } + 50% { filter: brightness(1.3) saturate(1.2); } +} + +/* ── Problem list ──────────────────────────────────────────────────────────── */ +.problems-section { + margin-top: 28px; + padding-top: 20px; + border-top: 2px solid var(--border); +} + +.problems-section-title { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-muted); + margin-bottom: 14px; +} + +.problem-group { + margin-bottom: 18px; +} + +.problem-group-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} + +.problem-group-label { + font-size: 0.82rem; + font-weight: 700; + color: var(--text); +} + +.problem-group-count { + font-size: 0.68rem; + font-weight: 600; + background: var(--surface-3); + color: var(--text-muted); + padding: 1px 8px; + border-radius: 10px; +} + +.problem-list-row { + display: grid; + grid-template-columns: 8px minmax(0, 1.2fr) minmax(0, 2.5fr) auto; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 6px; + margin-bottom: 3px; + background: var(--surface); + border: 1px solid var(--border); +} + +.problem-list-row:hover { + background: var(--surface-2); +} + +.pl-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.pl-host { + font-size: 1rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pl-desc { + font-size: 1rem; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pl-time { + font-size: 1rem; + color: var(--text-muted); + white-space: nowrap; +} + +/* ── Modal ─────────────────────────────────────────────────────────────────── */ +.modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.35); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + backdrop-filter: blur(2px); +} + +.modal-panel { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 14px; + width: 100%; + max-width: 560px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.18); + animation: modal-in 0.16s ease-out; +} + +@keyframes modal-in { + from { opacity: 0; transform: scale(0.96) translateY(6px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + background: var(--surface); +} + +.modal-title-wrap { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.modal-sev-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.modal-header h2 { + font-size: 0.95rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.modal-close { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 6px; + flex-shrink: 0; + transition: background 0.12s, color 0.12s; +} + +.modal-close:hover { background: var(--surface-3); color: var(--text); } + +.modal-body { + overflow-y: auto; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.host-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; +} + +.host-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.host-name { + font-size: 0.85rem; + font-weight: 600; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sev-badge { + font-size: 0.65rem; + font-weight: 700; + padding: 3px 9px; + border-radius: 20px; + white-space: nowrap; + flex-shrink: 0; + color: #fff; +} + +.problem-list { display: flex; flex-direction: column; gap: 6px; } + +.problem-row { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.problem-pip { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + margin-top: 5px; +} + +.problem-text { font-size: 0.78rem; line-height: 1.4; } +.problem-time { font-size: 0.68rem; color: var(--text-muted); margin-top: 1px; } + +.no-problems { + font-size: 0.78rem; + color: var(--c-ok); + font-weight: 500; +} + +.modal-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 32px; +} + +/* ── Responsive ────────────────────────────────────────────────────────────── */ +@media (max-width: 700px) { + :root { + --hex-w: 95px; /* 110 × 0.866 */ + --hex-h: 110px; + --hex-gap: 4px; + } + + .countries-wrap { flex-direction: column; } + .country-col { border-right: none; border-bottom: 1px solid var(--border); } + .hex-label { font-size: 0.82rem; max-width: 68px; } +} diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..53efccd --- /dev/null +++ b/readme.txt @@ -0,0 +1,2 @@ +zab user zabdash / lkjasfD08o2j!$%hrkn +API 8899bc47f78c8c515c602cf54399fd59fa2d74552182d2ae1d527a995e4fac06 \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..c92c5f1 --- /dev/null +++ b/server.js @@ -0,0 +1,301 @@ +require('dotenv').config(); +const express = require('express'); +const axios = require('axios'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 3000; +const ZABBIX_URL = (process.env.ZABBIX_URL || 'https://monitor.stranto.com').replace(/\/$/, ''); +const ZABBIX_TOKEN = process.env.ZABBIX_TOKEN || ''; +const COUNTRY_TAG = process.env.COUNTRY_TAG || 'country'; +const CUSTOMER_TAG_VALUE = process.env.CUSTOMER_TAG_VALUE || 'QWE'; + +app.use(express.static(path.join(__dirname, 'public'))); + +// ── Zabbix JSON-RPC helper ──────────────────────────────────────────────────── + +async function zabbix(method, params) { + const { data } = await axios.post( + `${ZABBIX_URL}/api_jsonrpc.php`, + { jsonrpc: '2.0', method, params, id: 1 }, + { timeout: 20000, headers: { Authorization: `Bearer ${ZABBIX_TOKEN}` } } + ); + if (data.error) { + throw new Error(data.error.data || data.error.message || 'Zabbix API error'); + } + return data.result; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getTag(tags, name) { + const t = tags.find(t => t.tag === name); + return t ? t.value : null; +} + +function worstSeverity(triggers) { + // Severity 0 (Not classified) and 1 (Information) are treated as OK + const relevant = (triggers || []).filter(t => parseInt(t.priority, 10) >= 2); + if (relevant.length === 0) return -1; + return Math.max(...relevant.map(t => parseInt(t.priority, 10))); +} + +function problemCount(triggers) { + return (triggers || []).filter(t => parseInt(t.priority, 10) >= 2).length; +} + +// ── Core data fetch ─────────────────────────────────────────────────────────── + +async function fetchKFCData() { + const hosts = await zabbix('host.get', { + output: ['hostid', 'host', 'name'], + tags: [{ tag: 'customer', value: CUSTOMER_TAG_VALUE, operator: '1' }], + selectTags: 'extend', + selectHostGroups: ['groupid', 'name'], + }); + + if (hosts.length === 0) return { hosts: [], triggersByHost: {} }; + + const hostIds = hosts.map(h => h.hostid); + + const triggers = await zabbix('trigger.get', { + output: ['triggerid', 'description', 'priority', 'lastchange'], + hostids: hostIds, + monitored: true, + filter: { value: 1 }, + withLastEventUnacknowledged: true, + expandDescription: true, + selectHosts: ['hostid', 'name'], + sortfield: 'priority', + sortorder: 'DESC', + }); + + const triggersByHost = {}; + for (const trigger of triggers) { + for (const host of trigger.hosts) { + if (!triggersByHost[host.hostid]) triggersByHost[host.hostid] = []; + triggersByHost[host.hostid].push(trigger); + } + } + + return { hosts, triggersByHost }; +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + +app.get('/api/countries', async (req, res) => { + try { + const hosts = await zabbix('host.get', { + output: ['hostid'], + tags: [{ tag: 'customer', value: CUSTOMER_TAG_VALUE, operator: '1' }], + selectTags: 'extend', + }); + const countries = [...new Set( + hosts.map(h => getTag(h.tags, COUNTRY_TAG)).filter(Boolean) + )].sort(); + res.json(countries); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.get('/api/restaurants', async (req, res) => { + try { + const country = req.query.country || ''; + const { hosts, triggersByHost } = await fetchKFCData(); + + const map = {}; + for (const host of hosts) { + const location = getTag(host.tags, 'location'); + const hostCountry = getTag(host.tags, COUNTRY_TAG); + if (!location) continue; + if (country && hostCountry && hostCountry.toLowerCase() !== country.toLowerCase()) continue; + + if (!map[location]) { + map[location] = { location, country: hostCountry, hosts: [], triggers: [] }; + } + map[location].hosts.push(host); + map[location].triggers.push(...(triggersByHost[host.hostid] || [])); + } + + const result = Object.values(map) + .map(loc => { + const problems = []; + for (const host of loc.hosts) { + for (const t of (triggersByHost[host.hostid] || [])) { + if (parseInt(t.priority, 10) >= 2) { + problems.push({ + description: t.description, + priority: parseInt(t.priority, 10), + lastchange: parseInt(t.lastchange, 10), + hostName: host.name, + }); + } + } + } + problems.sort((a, b) => b.priority - a.priority || b.lastchange - a.lastchange); + return { + location: loc.location, + country: loc.country, + hostCount: loc.hosts.length, + problemCount: problems.length, + severity: worstSeverity(loc.triggers), + problems, + }; + }) + .sort((a, b) => { + if ((a.country || '') < (b.country || '')) return -1; + if ((a.country || '') > (b.country || '')) return 1; + return a.location.localeCompare(b.location); + }); + + res.json(result); + } catch (e) { + console.error(e); + res.status(500).json({ error: e.message }); + } +}); + +app.get('/api/devices', async (req, res) => { + try { + const country = req.query.country || ''; + const { hosts, triggersByHost } = await fetchKFCData(); + + const map = {}; + for (const host of hosts) { + const hostCountry = getTag(host.tags, COUNTRY_TAG); + if (country && hostCountry && hostCountry.toLowerCase() !== country.toLowerCase()) continue; + + for (const group of host.hostgroups) { + const key = `${group.groupid}__${hostCountry || ''}`; + if (!map[key]) { + map[key] = { groupid: group.groupid, name: group.name, country: hostCountry, hosts: [], triggers: [] }; + } + map[key].hosts.push(host); + map[key].triggers.push(...(triggersByHost[host.hostid] || [])); + } + } + + const result = Object.values(map) + .map(g => { + const problems = []; + for (const host of g.hosts) { + for (const t of (triggersByHost[host.hostid] || [])) { + if (parseInt(t.priority, 10) >= 2) { + problems.push({ + description: t.description, + priority: parseInt(t.priority, 10), + lastchange: parseInt(t.lastchange, 10), + hostName: host.name, + }); + } + } + } + problems.sort((a, b) => b.priority - a.priority || b.lastchange - a.lastchange); + return { + groupid: g.groupid, + name: g.name, + country: g.country, + hostCount: g.hosts.length, + problemCount: problems.length, + severity: worstSeverity(g.triggers), + problems, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + res.json(result); + } catch (e) { + console.error(e); + res.status(500).json({ error: e.message }); + } +}); + +app.get('/api/detail', async (req, res) => { + try { + const { type, id } = req.query; + if (!type || !id) return res.status(400).json({ error: 'type and id required' }); + + const { hosts, triggersByHost } = await fetchKFCData(); + + const filtered = type === 'restaurant' + ? hosts.filter(h => getTag(h.tags, 'location') === id) + : hosts.filter(h => h.hostgroups.some(g => g.groupid === id)); + + const items = filtered.map(host => ({ + hostid: host.hostid, + name: host.name, + host: host.host, + tags: host.tags, + severity: worstSeverity(triggersByHost[host.hostid] || []), + problems: (triggersByHost[host.hostid] || []) + .filter(t => parseInt(t.priority, 10) >= 2) + .map(t => ({ + description: t.description, + priority: parseInt(t.priority, 10), + lastchange: new Date(parseInt(t.lastchange, 10) * 1000).toLocaleString(), + })), + })); + + res.json(items); + } catch (e) { + console.error(e); + res.status(500).json({ error: e.message }); + } +}); + +app.get('/api/config', (req, res) => { + res.json({ zabbixUrl: ZABBIX_URL, customerTagValue: CUSTOMER_TAG_VALUE }); +}); + +app.get('/api/stats', async (req, res) => { + try { + const hosts = await zabbix('host.get', { + output: ['hostid'], + tags: [{ tag: 'customer', value: CUSTOMER_TAG_VALUE, operator: '1' }], + selectTags: 'extend', + }); + + // Group host IDs by country + const byCountry = {}; + for (const host of hosts) { + const country = getTag(host.tags, COUNTRY_TAG) || ''; + if (!byCountry[country]) byCountry[country] = []; + byCountry[country].push(host.hostid); + } + + // Fetch item + trigger counts per country in parallel + const stats = {}; + await Promise.all(Object.entries(byCountry).map(async ([country, hostIds]) => { + const [itemCount, triggerCount] = await Promise.all([ + zabbix('item.get', { countOutput: true, hostids: hostIds, monitored: true }), + zabbix('trigger.get', { countOutput: true, hostids: hostIds, monitored: true }), + ]); + stats[country] = { + hostCount: hostIds.length, + itemCount: parseInt(itemCount, 10), + triggerCount: parseInt(triggerCount, 10), + }; + })); + + res.json(stats); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.get('/api/health', async (req, res) => { + try { + const version = await zabbix('apiinfo.version', {}); + res.json({ ok: true, zabbixVersion: version }); + } catch (e) { + res.status(503).json({ ok: false, error: e.message }); + } +}); + +// ── Start ───────────────────────────────────────────────────────────────────── + +app.listen(PORT, () => { + console.log(`Zabbix KFC Dashboard → http://localhost:${PORT}`); + if (!ZABBIX_TOKEN) console.warn('Warning: ZABBIX_TOKEN is not set'); +});