diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 7445ba174..0565f9f2f 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -236,6 +236,53 @@ jobs: path: ./coverage/coverage.lcov min_coverage: 0.0 + Test-Frontend: + name: Test Frontend and Coverage + runs-on: ubuntu-latest + needs: [Code-Quality-Checks] # or [Test-Application] if you want backend ready + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Cache node modules + uses: actions/cache@v4 + with: + path: frontend/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install frontend dependencies + working-directory: frontend + run: npm install + + - name: Run frontend tests with coverage + working-directory: frontend + run: npm run test -- --coverage + + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: frontend/coverage/lcov.info + fail_ci_if_error: true + verbose: true + + - name: Test acceptable level of code coverage + uses: VeryGoodOpenSource/very_good_coverage@v3 + with: + path: frontend/coverage/lcov.info + min_coverage: 80.0 + + Test-Docusaurus-Deployment: name: Test Deployment to https://docs-legacy.switchmap-ng.io runs-on: ubuntu-latest diff --git a/docs/docs/backend/Server.md b/docs/docs/backend/Server.md index a0f70ec85..a807a4f5b 100644 --- a/docs/docs/backend/Server.md +++ b/docs/docs/backend/Server.md @@ -2030,6 +2030,28 @@ class Query(graphene.ObjectType) Define GraphQL queries. + + +#### resolve\_devices + +```python +def resolve_devices(root, info, hostname=None, **kwargs) +``` + +Resolve and return devices from the database. + +**Arguments**: + +- `root` - The root object (not used here). +- `info` - GraphQL resolver info, used to get the query context. +- `hostname` _str, optional_ - If provided, filters by this hostname. +- `**kwargs` - Additional arguments (ignored). + + +**Returns**: + +- `sqlalchemy.orm.Query` - A query object for the matching Device. + #### resolve\_deviceMetrics diff --git a/docs/docs/frontend/components/ConnectionDetails.md b/docs/docs/frontend/components/ConnectionDetails.md index bbcc565f8..04330394f 100644 --- a/docs/docs/frontend/components/ConnectionDetails.md +++ b/docs/docs/frontend/components/ConnectionDetails.md @@ -10,18 +10,18 @@ ### ConnectionDetails() -> **ConnectionDetails**(`__namedParameters`): `Element` +> **ConnectionDetails**(`__namedParameters`): `null` | `Element` -Defined in: [components/ConnectionDetails.tsx:34](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/components/ConnectionDetails.tsx#L34) +Defined in: [components/ConnectionDetails.tsx:78](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/components/ConnectionDetails.tsx#L78) #### Parameters ##### \_\_namedParameters -###### device +###### device -[`DeviceNode`](../types/graphql/GetZoneDevices.md#devicenode) +`DeviceNode` #### Returns -`Element` +`null` | `Element` diff --git a/docs/docs/frontend/components/TopologyChart.md b/docs/docs/frontend/components/TopologyChart.md index 39e2508e7..232c3ee10 100644 --- a/docs/docs/frontend/components/TopologyChart.md +++ b/docs/docs/frontend/components/TopologyChart.md @@ -12,7 +12,7 @@ > **TopologyChart**(`__namedParameters`): `Element` -Defined in: [components/TopologyChart.tsx:34](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/components/TopologyChart.tsx#L34) +Defined in: [components/TopologyChart.tsx:32](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/components/TopologyChart.tsx#L32) #### Parameters diff --git a/docs/docs/frontend/components/ZoneDropdown.md b/docs/docs/frontend/components/ZoneDropdown.md index 37d5184f5..424ab31e8 100644 --- a/docs/docs/frontend/components/ZoneDropdown.md +++ b/docs/docs/frontend/components/ZoneDropdown.md @@ -12,7 +12,7 @@ > **ZoneDropdown**(`__namedParameters`): `Element` -Defined in: [components/ZoneDropdown.tsx:34](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/components/ZoneDropdown.tsx#L34) +Defined in: [components/ZoneDropdown.tsx:30](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/components/ZoneDropdown.tsx#L30) #### Parameters diff --git a/docs/docs/frontend/devices/[id]/page.md b/docs/docs/frontend/devices/[id]/page.md index 258a0b2c9..a0c4c3d5b 100644 --- a/docs/docs/frontend/devices/[id]/page.md +++ b/docs/docs/frontend/devices/[id]/page.md @@ -10,10 +10,10 @@ ### default() -> **default**(): `null` \| `Element` +> **default**(): `Element` -Defined in: [devices/\[id\]/page.tsx:78](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/devices/[id]/page.tsx#L78) +Defined in: [devices/\[id\]/page.tsx:33](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/devices/[id]/page.tsx#L33) #### Returns -`null` \| `Element` +`Element` diff --git a/docs/docs/frontend/modules.md b/docs/docs/frontend/modules.md index 32ccdf523..66cf4c009 100644 --- a/docs/docs/frontend/modules.md +++ b/docs/docs/frontend/modules.md @@ -7,15 +7,11 @@ ## Modules - [components/ConnectionDetails](components/ConnectionDetails.md) -- [components/DeviceDetails](components/DeviceDetails.md) - [components/DevicesOverview](components/DevicesOverview.md) -- [components/HistoricalChart](components/HistoricalChart.md) -- [components/LineChartWrapper](components/LineChartWrapper.md) - [components/Sidebar](components/Sidebar.md) - [components/TopologyChart](components/TopologyChart.md) - [components/ZoneDropdown](components/ZoneDropdown.md) - [devices/\[id\]/page](devices/[id]/page.md) -- [history/page](history/page.md) - [layout](layout.md) - [page](page.md) - [theme-toggle](theme-toggle.md) @@ -23,4 +19,3 @@ - [types/graphql/GetZoneDevices](types/graphql/GetZoneDevices.md) - [utils/stringUtils](utils/stringUtils.md) - [utils/time](utils/time.md) -- [utils/timeStamp](utils/timeStamp.md) diff --git a/docs/docs/frontend/types/graphql/GetZoneDevices.md b/docs/docs/frontend/types/graphql/GetZoneDevices.md index 98214222b..39b5cc7d9 100644 --- a/docs/docs/frontend/types/graphql/GetZoneDevices.md +++ b/docs/docs/frontend/types/graphql/GetZoneDevices.md @@ -12,7 +12,7 @@ > **DeviceEdge** = `object` -Defined in: [types/graphql/GetZoneDevices.ts:50](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L50) +Defined in: [types/graphql/GetZoneDevices.ts:49](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L49) #### Properties @@ -20,7 +20,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:50](https://github.com/PalisadoesFo > **node**: [`DeviceNode`](#devicenode) -Defined in: [types/graphql/GetZoneDevices.ts:51](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L51) +Defined in: [types/graphql/GetZoneDevices.ts:50](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L50) *** @@ -56,12 +56,6 @@ Defined in: [types/graphql/GetZoneDevices.ts:41](https://github.com/PalisadoesFo Defined in: [types/graphql/GetZoneDevices.ts:46](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L46) -##### lastPolled - -> **lastPolled**: `number` \| `null` - -Defined in: [types/graphql/GetZoneDevices.ts:47](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L47) - ##### sysDescription > **sysDescription**: `string` @@ -92,7 +86,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:43](https://github.com/PalisadoesFo > **Devices** = `object` -Defined in: [types/graphql/GetZoneDevices.ts:61](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L61) +Defined in: [types/graphql/GetZoneDevices.ts:60](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L60) #### Properties @@ -100,7 +94,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:61](https://github.com/PalisadoesFo > **edges**: [`DeviceEdge`](#deviceedge)[] -Defined in: [types/graphql/GetZoneDevices.ts:62](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L62) +Defined in: [types/graphql/GetZoneDevices.ts:61](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L61) *** @@ -108,7 +102,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:62](https://github.com/PalisadoesFo > **GetZoneDevicesData** = `object` -Defined in: [types/graphql/GetZoneDevices.ts:74](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L74) +Defined in: [types/graphql/GetZoneDevices.ts:73](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L73) #### Properties @@ -116,7 +110,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:74](https://github.com/PalisadoesFo > **data**: `object` -Defined in: [types/graphql/GetZoneDevices.ts:75](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L75) +Defined in: [types/graphql/GetZoneDevices.ts:74](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L74) ###### zone @@ -126,7 +120,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:75](https://github.com/PalisadoesFo > `optional` **errors**: `object`[] -Defined in: [types/graphql/GetZoneDevices.ts:78](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L78) +Defined in: [types/graphql/GetZoneDevices.ts:77](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L77) ###### message @@ -138,7 +132,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:78](https://github.com/PalisadoesFo > **GetZoneDevicesVars** = `object` -Defined in: [types/graphql/GetZoneDevices.ts:81](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L81) +Defined in: [types/graphql/GetZoneDevices.ts:80](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L80) #### Properties @@ -146,7 +140,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:81](https://github.com/PalisadoesFo > **id**: `string` -Defined in: [types/graphql/GetZoneDevices.ts:82](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L82) +Defined in: [types/graphql/GetZoneDevices.ts:81](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L81) *** @@ -217,7 +211,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:35](https://github.com/PalisadoesFo > **Zone** = `object` -Defined in: [types/graphql/GetZoneDevices.ts:69](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L69) +Defined in: [types/graphql/GetZoneDevices.ts:68](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L68) #### Properties @@ -225,7 +219,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:69](https://github.com/PalisadoesFo > **devices**: [`Devices`](#devices) -Defined in: [types/graphql/GetZoneDevices.ts:70](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L70) +Defined in: [types/graphql/GetZoneDevices.ts:69](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L69) *** @@ -233,7 +227,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:70](https://github.com/PalisadoesFo > **ZoneEdge** = `object` -Defined in: [types/graphql/GetZoneDevices.ts:57](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L57) +Defined in: [types/graphql/GetZoneDevices.ts:56](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L56) #### Properties @@ -241,7 +235,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:57](https://github.com/PalisadoesFo > **node**: [`ZoneNode`](#zonenode) -Defined in: [types/graphql/GetZoneDevices.ts:58](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L58) +Defined in: [types/graphql/GetZoneDevices.ts:57](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L57) *** @@ -249,7 +243,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:58](https://github.com/PalisadoesFo > **ZoneNode** = `object` -Defined in: [types/graphql/GetZoneDevices.ts:53](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L53) +Defined in: [types/graphql/GetZoneDevices.ts:52](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L52) #### Properties @@ -257,7 +251,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:53](https://github.com/PalisadoesFo > **devices**: [`Devices`](#devices) -Defined in: [types/graphql/GetZoneDevices.ts:54](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L54) +Defined in: [types/graphql/GetZoneDevices.ts:53](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L53) *** @@ -265,7 +259,7 @@ Defined in: [types/graphql/GetZoneDevices.ts:54](https://github.com/PalisadoesFo > **Zones** = `object` -Defined in: [types/graphql/GetZoneDevices.ts:65](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L65) +Defined in: [types/graphql/GetZoneDevices.ts:64](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L64) #### Properties @@ -273,4 +267,4 @@ Defined in: [types/graphql/GetZoneDevices.ts:65](https://github.com/PalisadoesFo > **edges**: [`ZoneEdge`](#zoneedge)[] -Defined in: [types/graphql/GetZoneDevices.ts:66](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L66) +Defined in: [types/graphql/GetZoneDevices.ts:65](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/types/graphql/GetZoneDevices.ts#L65) diff --git a/docs/docs/frontend/utils/stringUtils.md b/docs/docs/frontend/utils/stringUtils.md index c80b24f30..bee53d22e 100644 --- a/docs/docs/frontend/utils/stringUtils.md +++ b/docs/docs/frontend/utils/stringUtils.md @@ -12,7 +12,7 @@ > **truncateLines**(`str`, `options?`): `string` -Defined in: [utils/stringUtils.ts:8](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/utils/stringUtils.ts#L8) +Defined in: [utils/stringUtils.ts:5](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/utils/stringUtils.ts#L5) Truncates a string to a specified number of lines with optional max length. Adds line breaks and an ellipsis if truncated. @@ -23,12 +23,8 @@ Adds line breaks and an ellipsis if truncated. `string` -The string to truncate. - ##### options? -Optional settings for lines and maxLength. - ###### lines? `number` @@ -40,5 +36,3 @@ Optional settings for lines and maxLength. #### Returns `string` - -The truncated string with line breaks and ellipsis if applicable. diff --git a/docs/docs/frontend/utils/time.md b/docs/docs/frontend/utils/time.md index 1abfc406f..f5a1aa27e 100644 --- a/docs/docs/frontend/utils/time.md +++ b/docs/docs/frontend/utils/time.md @@ -12,9 +12,7 @@ > **formatUptime**(`hundredths`): `string` -Defined in: [utils/time.ts:6](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/utils/time.ts#L6) - -Converts uptime in hundredths of a second to a human-readable format (days, hours, minutes, seconds). +Defined in: [utils/time.ts:2](https://github.com/PalisadoesFoundation/switchmap-ng/blob/develop/frontend/src/app/utils/time.ts#L2) #### Parameters @@ -22,10 +20,6 @@ Converts uptime in hundredths of a second to a human-readable format (days, hour `number` -Uptime in hundredths of a second. - #### Returns `string` - -A formatted string representing the uptime. diff --git a/frontend/.gitignore b/frontend/.gitignore index 37988a12a..2f56e63ab 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -13,12 +13,20 @@ # testing /coverage +# next.js +/.next/ +/out/ +/.next/ +/out/ +.next/cache + # production /build # misc .DS_Store *.pem +*.log # debug npm-debug.log* @@ -26,6 +34,10 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* +# env files (can opt-in for committing if needed) +.env* +.env.*.local + # vercel .vercel @@ -33,68 +45,95 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# logs -logs -*.log -logs/* -*.log.* - -# OS -Thumbs.db -ehthumbs.db -Icon? -Desktop.ini - # IDEs and editors .vscode/ .idea/ *.sublime-workspace *.sublime-project +# OS generated files +Thumbs.db +ehthumbs.db +Desktop.ini + # Mac system files .AppleDouble .LSOverride -# npm package lock +# npm package files package-lock.json yarn.lock +pnpm-lock.yaml -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache +# local build artifacts +dist/ +tmp/ +temp/ +.cache/ +.nyc_output/ +bower_components/ -# Optional stylelint cache -.stylelintcache +# Storybook +storybook-static/ -# Optional REPL history -.node_repl_history +# Svelte +.svelte-kit/ -# Output of 'npm pack' +# Misc +*.swp +*.swo +*.bak +*.orig +*.rej +*.tmp +*.temp +*.pid +*.seed +*.pid.lock *.tgz -# Parcel-bundler cache (if used) -.cache - -# SASS cache -.sass-cache +# JetBrains IDEs +*.iml +*.ipr +*.iws +.idea/ -# Coverage directory used by tools like istanbul -coverage/ +# VS Code settings +.vscode/ +.history/ # Next.js build output .next/ +out/ +.next/cache/ -# Static exported files +# Next.js static export out/ -# Build output -build/ +# Next.js serverless functions output +.next/server/ -# Storybook build outputs -.out/ -storybook-static/ +# Next.js telemetry +.next/telemetry/ + +# Next.js build manifests +.next/static/ + +# Next.js cache +.next/cache/ + +# Next.js build traces +.next/trace + +# Next.js build logs +.next/build-manifest.json +.next/prerender-manifest.json +.next/routes-manifest.json +.next/export-marker.json +.next/BUILD_ID + +# Next.js image optimization cache +/public/_next/image/ # Local env files .env.local @@ -102,11 +141,8 @@ storybook-static/ .env.test.local .env.production.local -# Misc -*.swp -*.swo -*.bak -*.tmp -*.temp -*.orig -*.rej +# npm logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/frontend/README.md b/frontend/README.md index bad4123ee..e215bc4cc 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,81 +1,36 @@ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). -# SwitchMap-NG Frontend - -This folder contains the modern frontend for SwitchMap-NG, responsible for rendering network dashboards, visualizations, and device data. - ## Getting Started -> **Make sure the backend API server is running before starting the frontend.** -> Refer to the [Installation Guide](/docs/installation) for backend setup instructions. -> Set up the pre-commit hook to automatically generate documentation when committing changes: - -```bash -python scripts/setup_hooks.py - -``` -1. **Navigate to the frontend directory:** -```bash -cd frontend - -``` - -2. **Install dependencies:** - -```bash -npm install - -``` -3. **Start the development server:** +First, run the development server: ```bash npm run dev - +# or +yarn dev +# or +pnpm dev +# or +bun dev ``` -The frontend will be available at http://localhost:3000. +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. -## Project Overview +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. -This frontend interfaces with the SwitchMap-NG backend (Flask + GraphQL) to present real-time network data. Features include: +## Learn More -- Device and port dashboards -- Network topology visualization -- Theme-aware UI with dark/light modes -- Charts for bandwidth, CPU, memory, and uptime +To learn more about Next.js, take a look at the following resources: -## Tech Stack -- Next.js 15.3.3 (App Router) -- React 19 -- Tailwind CSS 4 -- TypeScript -- Theming using CSS custom properties and next-themes -- Fetch API for backend GraphQL communication +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. -### Directory Structure +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! -```txt -frontend/ -├── src/ -│ └── app/ -│ ├── components/ # UI components -│ ├── devices/ # Device-specific pages -│ ├── globals.css # Global styles -│ ├── layout.tsx # Root layout -│ ├── page.tsx # Main entry page -│ └── theme-toggle.tsx # Theme toggle -│ └── types/ # Shared TypeScript types -├── .env.local # Environment variables (not committed) -├── .gitignore -├── next.config.ts # Next.js config -├── tsconfig.json # TypeScript config -├── typedoc.json # Typedoc config -├── postcss.config.mjs # PostCSS config -├── eslint.config.mjs # ESLint config -├── package.json # Project metadata and scripts -├── package-lock.json -└── README.md # Frontend Read Me file -``` +## Deploy on Vercel +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a1a7b2207..2f757b90e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,25 +12,39 @@ "next": "15.3.4", "next-themes": "^0.4.6", "react": "^19.1.0", - "react-dom": "^19.1.0", + "react-dom": "^19.0.0", "react-icons": "^5.5.0", - "recharts": "^3.1.0", "vis-network": "^9.1.13" }, "devDependencies": { "@eslint/eslintrc": "^3", - "@tailwindcss/postcss": "^4", + "@tailwindcss/postcss": "^4.1.12", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.7.0", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", "eslint": "^9", "eslint-config-next": "15.3.4", + "jsdom": "^26.1.0", "tailwindcss": "^4", "typedoc": "^0.28.7", "typedoc-plugin-markdown": "^4.7.1", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -38,70 +52,983 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", + "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.3", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", + "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", + "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", + "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@egjs/hammerjs": { - "version": "2.0.17", - "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", - "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@types/hammerjs": "^2.0.36" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.8.0" + "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -734,6 +1661,24 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -747,35 +1692,42 @@ "node": ">=18.0.0" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.0.0" + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { @@ -790,9 +1742,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -977,59 +1929,364 @@ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8" - } + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", + "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.49.0.tgz", + "integrity": "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.49.0.tgz", + "integrity": "sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.49.0.tgz", + "integrity": "sha512-99kMMSMQT7got6iYX3yyIiJfFndpojBmkHfTc1rIje8VbjhmqBXE+nb7ZZP3A5skLyujvT0eIUCUsxAe6NjWbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.49.0.tgz", + "integrity": "sha512-y8cXoD3wdWUDpjOLMKLx6l+NFz3NlkWKcBCBfttUn+VGSfgsQ5o/yDUGtzE9HvsodkP0+16N0P4Ty1VuhtRUGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.49.0.tgz", + "integrity": "sha512-3mY5Pr7qv4GS4ZvWoSP8zha8YoiqrU+e0ViPvB549jvliBbdNLrg2ywPGkgLC3cmvN8ya3za+Q2xVyT6z+vZqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.49.0.tgz", + "integrity": "sha512-C9KzzOAQU5gU4kG8DTk+tjdKjpWhVWd5uVkinCwwFub2m7cDYLOdtXoMrExfeBmeRy9kBQMkiyJ+HULyF1yj9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.49.0.tgz", + "integrity": "sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.49.0.tgz", + "integrity": "sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.49.0.tgz", + "integrity": "sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.49.0.tgz", + "integrity": "sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.49.0.tgz", + "integrity": "sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.49.0.tgz", + "integrity": "sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.49.0.tgz", + "integrity": "sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.49.0.tgz", + "integrity": "sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.49.0.tgz", + "integrity": "sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.49.0.tgz", + "integrity": "sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.49.0.tgz", + "integrity": "sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.49.0.tgz", + "integrity": "sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.49.0.tgz", + "integrity": "sha512-gq5aW/SyNpjp71AAzroH37DtINDcX1Qw2iv9Chyz49ZgdOP3NV8QCyKZUrGsYX9Yyggj5soFiRCgsL3HwD8TdA==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=12.4.0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@reduxjs/toolkit": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", - "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.49.0.tgz", + "integrity": "sha512-gEtqFbzmZLFk2xKh7g0Rlo8xzho8KrEFEkzvHbfUGkrgXOpZ4XagQ6n+wIZFNh1nTb8UD16J4nFSFKXYgnbdBg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^10.0.3", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -1122,25 +2379,25 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.10.tgz", - "integrity": "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", + "integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.10" + "tailwindcss": "4.1.12" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.10.tgz", - "integrity": "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1152,24 +2409,24 @@ "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.10", - "@tailwindcss/oxide-darwin-arm64": "4.1.10", - "@tailwindcss/oxide-darwin-x64": "4.1.10", - "@tailwindcss/oxide-freebsd-x64": "4.1.10", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", - "@tailwindcss/oxide-linux-x64-musl": "4.1.10", - "@tailwindcss/oxide-wasm32-wasi": "4.1.10", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.10.tgz", - "integrity": "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz", + "integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==", "cpu": [ "arm64" ], @@ -1184,9 +2441,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.10.tgz", - "integrity": "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz", + "integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==", "cpu": [ "arm64" ], @@ -1201,9 +2458,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.10.tgz", - "integrity": "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", "cpu": [ "x64" ], @@ -1218,9 +2475,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.10.tgz", - "integrity": "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz", + "integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==", "cpu": [ "x64" ], @@ -1235,9 +2492,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.10.tgz", - "integrity": "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz", + "integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==", "cpu": [ "arm" ], @@ -1252,9 +2509,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.10.tgz", - "integrity": "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz", + "integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==", "cpu": [ "arm64" ], @@ -1269,9 +2526,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.10.tgz", - "integrity": "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz", + "integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==", "cpu": [ "arm64" ], @@ -1286,9 +2543,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.10.tgz", - "integrity": "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz", + "integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==", "cpu": [ "x64" ], @@ -1303,9 +2560,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.10.tgz", - "integrity": "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz", + "integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==", "cpu": [ "x64" ], @@ -1320,9 +2577,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.10.tgz", - "integrity": "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz", + "integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1338,11 +2595,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.10", - "@tybys/wasm-util": "^0.9.0", + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", "tslib": "^2.8.0" }, "engines": { @@ -1350,9 +2607,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz", - "integrity": "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", + "integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==", "cpu": [ "arm64" ], @@ -1367,9 +2624,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.10.tgz", - "integrity": "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz", + "integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==", "cpu": [ "x64" ], @@ -1384,17 +2641,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.10.tgz", - "integrity": "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.12.tgz", + "integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.10", - "@tailwindcss/oxide": "4.1.10", + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", "postcss": "^8.4.41", - "tailwindcss": "4.1.10" + "tailwindcss": "4.1.12" } }, "node_modules/@tanstack/react-table": { @@ -1430,6 +2687,93 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.8.0.tgz", + "integrity": "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", @@ -1441,6 +2785,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -1504,6 +2911,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2141,6 +3555,198 @@ "win32" ] }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", + "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.14", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.2.4" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2164,6 +3770,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2181,6 +3797,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2374,6 +4000,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2381,6 +4017,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.5.tgz", + "integrity": "sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.30", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2458,6 +4113,39 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -2469,6 +4157,16 @@ "node": ">=10.16.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2530,9 +4228,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001726", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", - "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", + "version": "1.0.30001737", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", + "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", "funding": [ { "type": "opencollective", @@ -2549,6 +4247,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2566,6 +4281,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2656,6 +4381,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2671,6 +4403,27 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2806,6 +4559,20 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2878,12 +4645,29 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2927,6 +4711,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -2950,6 +4745,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2965,6 +4768,20 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.211", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.211.tgz", + "integrity": "sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -2973,9 +4790,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", - "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", "dependencies": { @@ -3116,6 +4933,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3186,6 +5010,58 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3615,6 +5491,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3631,6 +5517,16 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3692,6 +5588,13 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3772,6 +5675,38 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3813,6 +5748,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3883,6 +5828,27 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3896,6 +5862,32 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -3926,6 +5918,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4041,10 +6040,71 @@ "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, "node_modules/ignore": { @@ -4094,6 +6154,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4296,6 +6366,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", @@ -4381,6 +6461,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4540,6 +6627,60 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -4558,10 +6699,26 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "dev": true, "license": "MIT", "bin": { @@ -4588,6 +6745,59 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4974,6 +7184,30 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru-cache/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/lunr": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", @@ -4981,6 +7215,17 @@ "dev": true, "license": "MIT" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -4991,6 +7236,34 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-it": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", @@ -5050,6 +7323,16 @@ "node": ">=8.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5112,6 +7395,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5252,6 +7545,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.21", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", + "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5443,6 +7750,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5456,6 +7770,32 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5483,6 +7823,47 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5551,6 +7932,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5663,6 +8082,16 @@ } } }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/recharts": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.1.0.tgz", @@ -5690,6 +8119,20 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -5807,6 +8250,53 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz", + "integrity": "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.49.0", + "@rollup/rollup-android-arm64": "4.49.0", + "@rollup/rollup-darwin-arm64": "4.49.0", + "@rollup/rollup-darwin-x64": "4.49.0", + "@rollup/rollup-freebsd-arm64": "4.49.0", + "@rollup/rollup-freebsd-x64": "4.49.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", + "@rollup/rollup-linux-arm-musleabihf": "4.49.0", + "@rollup/rollup-linux-arm64-gnu": "4.49.0", + "@rollup/rollup-linux-arm64-musl": "4.49.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", + "@rollup/rollup-linux-ppc64-gnu": "4.49.0", + "@rollup/rollup-linux-riscv64-gnu": "4.49.0", + "@rollup/rollup-linux-riscv64-musl": "4.49.0", + "@rollup/rollup-linux-s390x-gnu": "4.49.0", + "@rollup/rollup-linux-x64-gnu": "4.49.0", + "@rollup/rollup-linux-x64-musl": "4.49.0", + "@rollup/rollup-win32-arm64-msvc": "4.49.0", + "@rollup/rollup-win32-ia32-msvc": "4.49.0", + "@rollup/rollup-win32-x64-msvc": "4.49.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5880,10 +8370,30 @@ "is-regex": "^1.2.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" } }, "node_modules/scheduler": { @@ -6095,6 +8605,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -6105,6 +8635,21 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/sirv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6121,6 +8666,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6143,6 +8702,60 @@ "node": ">=10.0.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6256,6 +8869,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6266,6 +8922,19 @@ "node": ">=4" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6279,6 +8948,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -6328,21 +9017,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz", - "integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar": { @@ -6363,12 +9063,67 @@ "node": ">=18" } }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -6414,6 +9169,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6427,6 +9232,42 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -6440,6 +9281,27 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6695,6 +9557,37 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.9.2" } }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -6702,16 +9595,7 @@ "dev": true, "license": "BSD-2-Clause", "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "punycode": "^2.1.0" } }, "node_modules/uuid": { @@ -6728,28 +9612,6 @@ "uuid": "dist/esm/bin/uuid" } }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vis-data": { "version": "7.1.10", "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.10.tgz", @@ -6801,6 +9663,301 @@ "component-emitter": "^1.3.0 || ^2.0.0" } }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", + "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6906,6 +10063,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6916,6 +10090,130 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 17856fb1f..b2a3c831e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,12 +1,16 @@ { "name": "frontend", "version": "0.1.0", + "type": "module", "private": true, "scripts": { "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest --ui", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@tanstack/react-table": "^8.21.3", @@ -15,21 +19,29 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", - "recharts": "^3.1.0", - "vis-network": "^9.1.13" + "vis-network": "^9.1.13", + "recharts": "^3.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", - "@tailwindcss/postcss": "^4", + "@tailwindcss/postcss": "^4.1.12", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.7.0", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", "eslint": "^9", "eslint-config-next": "15.3.4", + "jsdom": "^26.1.0", "tailwindcss": "^4", "typedoc": "^0.28.7", "typedoc-plugin-markdown": "^4.7.1", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.4" }, "repository": { "type": "git", diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs index c7bcb4b1e..b776c6223 100644 --- a/frontend/postcss.config.mjs +++ b/frontend/postcss.config.mjs @@ -1,5 +1,5 @@ -const config = { - plugins: ["@tailwindcss/postcss"], -}; - -export default config; +export default { + plugins: { + "@tailwindcss/postcss": {}, + } +} \ No newline at end of file diff --git a/frontend/src/app/components/ConnectionCharts.spec.tsx b/frontend/src/app/components/ConnectionCharts.spec.tsx new file mode 100644 index 000000000..ecd2019f7 --- /dev/null +++ b/frontend/src/app/components/ConnectionCharts.spec.tsx @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ConnectionCharts } from "./ConnectionCharts"; +import { mockDevice } from "./__mocks__/deviceMocks"; + +vi.stubGlobal("fetch", vi.fn()); + +// ---------- Helpers ---------- +const renderConnectionCharts = () => + render(); + +const openCustomRange = () => { + const button = screen.getByRole("button", { name: /Past 1 day/i }); + fireEvent.click(button); + fireEvent.click(screen.getByText("Custom range")); + + const startInput = screen.getByLabelText(/start date/i) as HTMLInputElement; + const endInput = screen.getByLabelText(/end date/i) as HTMLInputElement; + + return { startInput, endInput }; +}; + +const expandInterface = async (ifaceName = "Gig1/0/1") => { + const toggle = screen.getByText(ifaceName).closest("div")!; + fireEvent.click(toggle); + await waitFor(() => expect(screen.getByText("Download")).toBeInTheDocument()); +}; + +// ---------- Tests ---------- +describe("ConnectionCharts", () => { + beforeEach(() => { + (fetch as unknown as Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + devices: { + edges: [ + { + node: { + id: mockDevice.id, + hostname: mockDevice.hostname, + lastPolled: mockDevice.lastPolled, + l1interfaces: mockDevice.l1interfaces, + }, + }, + ], + }, + }, + }), + }); + }); + + // ---------- Rendering ---------- + it("renders interface names", () => { + renderConnectionCharts(); + expect(screen.getByText("Connection Charts")).toBeInTheDocument(); + expect(screen.getByText("Gig1/0/1")).toBeInTheDocument(); + }); + + // ---------- Interface interactions ---------- + it("expands and collapses interfaces", async () => { + renderConnectionCharts(); + const toggle = screen.getByText("Gig1/0/1").closest("div")!; + fireEvent.click(toggle); + + await waitFor(() => + expect(screen.getByText("Download")).toBeInTheDocument() + ); + + fireEvent.click(toggle); + await waitFor(() => + expect(screen.queryByText("Download")).not.toBeInTheDocument() + ); + + expect(screen.getByText("Gig1/0/1")).toBeInTheDocument(); + }); + + it("expands all and collapses all buttons", async () => { + renderConnectionCharts(); + fireEvent.click(screen.getByText("Expand All")); + await waitFor(() => + expect(screen.getByText("Download")).toBeInTheDocument() + ); + + fireEvent.click(screen.getByText("Collapse All")); + await waitFor(() => + expect(screen.queryByText("Download")).not.toBeInTheDocument() + ); + }); + + // ---------- Fetch ---------- + it("calls fetch with correct hostname", async () => { + renderConnectionCharts(); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("graphql"), + expect.objectContaining({ + method: "POST", + body: expect.stringContaining(mockDevice.hostname), + }) + ) + ); + }); + + it("sets error if fetch fails", async () => { + (fetch as unknown as Mock).mockRejectedValueOnce( + new Error("Network Error") + ); + renderConnectionCharts(); + await expandInterface(); + await waitFor(() => + expect(screen.getByText(/No data available/i)).toBeInTheDocument() + ); + }); + + // ---------- Download ---------- + it("downloads CSV when download button clicked", async () => { + const createObjectURLSpy = vi.fn(() => "blob:mock"); + const revokeObjectURLSpy = vi.fn(); + vi.stubGlobal("URL", { + createObjectURL: createObjectURLSpy, + revokeObjectURL: revokeObjectURLSpy, + }); + + renderConnectionCharts(); + await expandInterface(); + + fireEvent.click(screen.getByText("Download")); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(revokeObjectURLSpy).toHaveBeenCalled(); + }); + + // ---------- Time ranges ---------- + it("covers 24h, 7d, 30d timeRange branches", async () => { + renderConnectionCharts(); + const dropdownButton = screen.getByRole("button", { name: /Past 1 day/i }); + + fireEvent.click(dropdownButton); + fireEvent.click(screen.getByText("Past 7 days")); + await waitFor(() => + expect(screen.getByText("Gig1/0/1")).toBeInTheDocument() + ); + + fireEvent.click(dropdownButton); + fireEvent.click(screen.getByText("Past 30 days")); + await waitFor(() => + expect(screen.getByText("Gig1/0/1")).toBeInTheDocument() + ); + }); + + // ---------- Custom range validations ---------- + describe("Custom range validations", () => { + beforeEach(() => { + render(); + }); + + it("shows error message if custom range exceeds 180 days", async () => { + const { startInput, endInput } = openCustomRange(); + + // Input range > 180 days + fireEvent.change(startInput, { target: { value: "2025-01-01" } }); + fireEvent.change(endInput, { target: { value: "2025-09-01" } }); + + await waitFor(() => + expect( + screen.getByText("Custom range cannot exceed 180 days.") + ).toBeInTheDocument() + ); + }); + it("accepts valid custom range without showing error", async () => { + const { startInput, endInput } = openCustomRange(); + + fireEvent.change(startInput, { target: { value: "2025-01-01" } }); + fireEvent.change(endInput, { target: { value: "2025-01-15" } }); + + expect( + screen.queryByText("Custom range cannot exceed 180 days.") + ).not.toBeInTheDocument(); + }); + + it("shows error when start date is after end date", async () => { + const { startInput, endInput } = openCustomRange(); + fireEvent.change(endInput, { target: { value: "2025-09-10" } }); + fireEvent.change(startInput, { target: { value: "2025-09-15" } }); + + await waitFor(() => + expect( + screen.getByText("Start date must be before end date.") + ).toBeInTheDocument() + ); + }); + + it("shows error when end date is before start date", async () => { + const { startInput, endInput } = openCustomRange(); + fireEvent.change(startInput, { target: { value: "2025-09-15" } }); + fireEvent.change(endInput, { target: { value: "2025-09-10" } }); + + await waitFor(() => + expect( + screen.getByText("End date must be after start date.") + ).toBeInTheDocument() + ); + }); + + it("errors if start date exceeds 180-day range from end date", async () => { + const { startInput, endInput } = openCustomRange(); + + fireEvent.change(endInput, { target: { value: "2025-08-01" } }); + fireEvent.change(startInput, { target: { value: "2025-01-01" } }); + + await waitFor(() => + expect( + screen.getByText("Custom range cannot exceed 180 days.") + ).toBeInTheDocument() + ); + }); + }); +}); diff --git a/frontend/src/app/components/ConnectionCharts.tsx b/frontend/src/app/components/ConnectionCharts.tsx new file mode 100644 index 000000000..aeb3d7b04 --- /dev/null +++ b/frontend/src/app/components/ConnectionCharts.tsx @@ -0,0 +1,496 @@ +"use client"; +import { useState, useEffect } from "react"; +import { FiPlus, FiMinus, FiDownload } from "react-icons/fi"; +import HistoricalChart from "./HistoricalChart"; +import { DeviceNode } from "../types/graphql/GetZoneDevices"; +import { InterfaceNode } from "../types/graphql/GetDeviceInterfaces"; +/** + * Tabs available for chart display. + * @remarks + * - "Traffic": Displays total traffic (in and out) in packets. + * - "Unicast": Displays unicast packet flow. + * - "NonUnicast": Displays non-unicast packet flow. + * - "Errors": Displays error packets. + * - "Discards": Displays discarded packets. + * - "Speed": Displays interface speed in Mbps. + * @typedef {("Traffic" | "Unicast" | "NonUnicast" | "Errors" | "Discards" | "Speed")} ChartTab + * @enum {ChartTab} + * @see {@link HistoricalChart} for rendering the charts. + * @see {@link ConnectionChartsProps} for component props. + * @interface ConnectionChartsProps + * @property {DeviceNode} device - The device for which to display connection charts. + */ + +type ChartTab = + | "Traffic" + | "Unicast" + | "NonUnicast" + | "Errors" + | "Discards" + | "Speed"; + +interface ChartDataPoint { + lastPolled: string; + value: number; +} + +interface ConnectionChartsProps { + device: DeviceNode; +} + +const QUERY = (hostname: string) => ` +{ + devices(hostname: "${hostname}") { + edges { + node { + id + hostname + sysName + lastPolled + l1interfaces { + edges { + node { + ifname + ifspeed + } + } + } + } + } + } +} +`; + +export function ConnectionCharts({ device }: ConnectionChartsProps) { + const [timeRange, setTimeRange] = useState("24h"); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [expandedIfaces, setExpandedIfaces] = useState>( + {} + ); + const [activeTabs, setActiveTabs] = useState>({}); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [data, setData] = useState< + Record> + >({}); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; // number of interfaces per page + + // pagination logic + const totalPages = Math.ceil(device.l1interfaces.edges.length / pageSize); + const paginatedIfaces = device.l1interfaces.edges.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize + ); + const TIME_RANGES = [ + { value: "24h", label: "Past 1 day" }, + { value: "7d", label: "Past 7 days" }, + { value: "30d", label: "Past 30 days" }, + { value: "custom", label: "Custom range" }, + ]; + + // Expand All / Collapse All + const expandAll = () => { + const newState = device.l1interfaces.edges.reduce((acc, { node }) => { + acc[node.ifname] = true; + return acc; + }, {} as Record); + setExpandedIfaces(newState); + }; + + const collapseAll = () => { + const newState = device.l1interfaces.edges.reduce((acc, { node }) => { + acc[node.ifname] = false; + return acc; + }, {} as Record); + setExpandedIfaces(newState); + }; + + useEffect(() => { + const ac = new AbortController(); + let cancelled = false; + + const fetchData = async () => { + try { + const res = await fetch( + process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || + "http://localhost:7000/switchmap/api/graphql", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: QUERY(device.hostname) }), + signal: ac.signal, + } + ); + + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const json = await res.json(); + if (cancelled) return; + + const edges = json?.data?.devices?.edges || []; + const newData: Record> = {}; + const now = new Date(); + let rangeStart: Date; + + if (timeRange === "24h") + rangeStart = new Date(now.getTime() - 24 * 3600 * 1000); + else if (timeRange === "7d") + rangeStart = new Date(now.getTime() - 7 * 24 * 3600 * 1000); + else if (timeRange === "30d") + rangeStart = new Date(now.getTime() - 30 * 24 * 3600 * 1000); + else rangeStart = new Date(0); + + edges.forEach(({ node }: any) => { + const lastPolled = new Date(node.lastPolled * 1000); + if ( + lastPolled < rangeStart || + (startDate && lastPolled < new Date(startDate)) || + (endDate && lastPolled > new Date(endDate)) + ) + return; + + node.l1interfaces.edges.forEach(({ node: iface }: any) => { + const ifname = iface.ifname; + if (!newData[ifname]) + newData[ifname] = { + Traffic: [], + Unicast: [], + NonUnicast: [], + Errors: [], + Discards: [], + Speed: [], + }; + + newData[ifname].Traffic.push({ + lastPolled: lastPolled.toISOString(), + value: (iface.ifinUcastPkts ?? 0) + (iface.ifoutUcastPkts ?? 0), + }); + newData[ifname].Unicast.push({ + lastPolled: lastPolled.toISOString(), + value: (iface.ifinUcastPkts ?? 0) + (iface.ifoutUcastPkts ?? 0), + }); + newData[ifname].NonUnicast.push({ + lastPolled: lastPolled.toISOString(), + value: (iface.ifinNUcastPkts ?? 0) + (iface.ifoutNUcastPkts ?? 0), + }); + newData[ifname].Errors.push({ + lastPolled: lastPolled.toISOString(), + value: (iface.ifinErrors ?? 0) + (iface.ifoutErrors ?? 0), + }); + newData[ifname].Discards.push({ + lastPolled: lastPolled.toISOString(), + value: (iface.ifinDiscards ?? 0) + (iface.ifoutDiscards ?? 0), + }); + if (iface.ifspeed != null) + newData[ifname].Speed.push({ + lastPolled: lastPolled.toISOString(), + value: iface.ifspeed, + }); + }); + }); + + setData(newData); + } catch (err: any) { + if (err.name === "AbortError") return; + setError(err.message); + } + }; + + fetchData(); + return () => { + cancelled = true; + ac.abort(); + }; + }, [device.hostname, timeRange, startDate, endDate]); + + const downloadCSV = (data: ChartDataPoint[], filename: string) => { + const csv = [ + "lastPolled,value", + ...data.map((p) => `${p.lastPolled},${p.value}`), + ].join("\n"); + const blob = new Blob([csv], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ {/* Centralized error alert */} + {error && ( +
+
+ {error} +
+
+ )} +
+

Connection Charts

+

+ View bandwidth, packet flow, errors, and discards per interface. +

+ + {/* Filters */} +
+ {/* Dropdown */} +
+ + + {dropdownOpen && ( +
+ {TIME_RANGES.map((r) => ( + + ))} +
+ )} +
+ + {/* Custom date inputs */} + {timeRange === "custom" && ( +
+
+ + { + const start = new Date(e.target.value); + const end = endDate ? new Date(endDate) : null; + + if (end && start > end) { + setError("Start date must be before end date."); + setTimeout(() => setError(""), 3000); + return; + } + + if ( + end && + (end.getTime() - start.getTime()) / + (1000 * 60 * 60 * 24) > + 180 + ) { + setError("Custom range cannot exceed 180 days."); + setTimeout(() => setError(""), 3000); + return; + } + + setStartDate(e.target.value); + }} + className="border border-gray-300 rounded px-2 py-1 bg-bg" + /> +
+
+ + { + const end = new Date(e.target.value); + const start = startDate ? new Date(startDate) : null; + + if (start && end < start) { + setError("End date must be after start date."); + setTimeout(() => setError(""), 3000); + return; + } + + if ( + start && + (end.getTime() - start.getTime()) / + (1000 * 60 * 60 * 24) > + 180 + ) { + setError("Custom range cannot exceed 180 days."); + setTimeout(() => setError(""), 3000); + return; + } + + setEndDate(e.target.value); + }} + className="border border-gray-300 rounded px-2 py-1 bg-bg" + /> +
+
+ )} + + {/* Expand / Collapse */} +
+ + +
+
+ + {/* Interfaces with Pagination */} +
+ {paginatedIfaces.map(({ node }: { node: InterfaceNode }) => { + const ifname = node.ifname; + const isExpanded = expandedIfaces[ifname]; + const currentTab = activeTabs[ifname] || "Traffic"; + const filteredData = data[ifname]?.[currentTab] || []; + + return ( +
+
+ setExpandedIfaces((prev) => ({ + ...prev, + [ifname]: !prev[ifname], + })) + } + > + {isExpanded ? : } +

{ifname}

+
+ + {isExpanded && ( + <> +
+ + {( + [ + "Traffic", + "Unicast", + "NonUnicast", + "Errors", + "Discards", + "Speed", + ] as ChartTab[] + ).map((tab) => ( + + ))} +
+ + {filteredData.length > 0 ? ( +
+ +
+ ) : ( +

+ No data available for the selected range. +

+ )} + + )} +
+ ); + })} + + {/* Pagination Controls */} +
+ + + Page {currentPage} of {totalPages} + + +
+
+
+
+ ); +} diff --git a/frontend/src/app/components/ConnectionDetails.spec.tsx b/frontend/src/app/components/ConnectionDetails.spec.tsx new file mode 100644 index 000000000..d6c0a2fc1 --- /dev/null +++ b/frontend/src/app/components/ConnectionDetails.spec.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { ConnectionDetails } from "./ConnectionDetails"; +import { mockDevice } from "./__mocks__/deviceMocks"; + +vi.mock("next/navigation", () => ({ useParams: () => ({ deviceId: "1" }) })); + +describe("ConnectionDetails Component", () => { + // ---------- Empty / null device ---------- + it("renders no data message when device is null", () => { + render(); + expect( + screen.getByText(/no interface data available/i) + ).toBeInTheDocument(); + }); + + // ---------- Table rendering ---------- + it("renders table with device interfaces", () => { + render(); + expect(screen.getByText(/connection details/i)).toBeInTheDocument(); + expect(screen.getByText("Port")).toBeInTheDocument(); + expect(screen.getByText("VLAN")).toBeInTheDocument(); + expect(screen.getByText("Mac Address")).toBeInTheDocument(); + }); + + // ---------- MAC / Manufacturer extraction ---------- + it("extracts MAC addresses correctly", () => { + render(); + expect( + screen.getByText((content) => content.includes("00:11:22:33:44:55")) + ).toBeInTheDocument(); + }); + + it("extracts manufacturers correctly", () => { + render(); + expect( + screen.getByText((content) => content.includes("Cisco")) + ).toBeInTheDocument(); + }); + + // ---------- Interface operational status ---------- + it("shows Active for ifoperstatus === 1", () => { + render(); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + + it("shows Disabled for ifoperstatus === 2", () => { + const disabledInterfaceDevice = { + ...mockDevice, + l1interfaces: { + edges: [ + { + node: { + ...mockDevice.l1interfaces.edges[0].node, + ifoperstatus: 2, + }, + }, + ], + }, + }; + render(); + expect(screen.getByText("Disabled")).toBeInTheDocument(); + }); + + it("shows N/A for undefined or other ifoperstatus", () => { + const naInterfaceDevice = { + ...mockDevice, + l1interfaces: { + edges: [ + { + node: { + ...mockDevice.l1interfaces.edges[0].node, + ifoperstatus: 3, + }, + }, + ], + }, + }; + render(); + expect(screen.getByText("N/A")).toBeInTheDocument(); + }); + + it("renders '-' when CDP / LLDP data is missing", () => { + const deviceWithMissingCDP = { + ...mockDevice, + l1interfaces: { + edges: [ + { + node: { + ...mockDevice.l1interfaces.edges[0].node, + cdpcachedeviceport: "", + lldpremsysname: "", + }, + }, + ], + }, + }; + + render(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/components/ConnectionDetails.tsx b/frontend/src/app/components/ConnectionDetails.tsx index f4b54cf5e..6ccf4572f 100644 --- a/frontend/src/app/components/ConnectionDetails.tsx +++ b/frontend/src/app/components/ConnectionDetails.tsx @@ -1,167 +1,162 @@ -"use client"; -import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; -import { - InterfaceEdge, - InterfaceNode, - Mac, - MacPort, - MacsEdge, -} from "@/app/types/graphql/GetDeviceInterfaces"; -import { DeviceNode } from "@/app/types/graphql/GetZoneDevices"; -/** - * ConnectionDetails component fetches and displays detailed information about a device's interfaces. - * It includes MAC addresses, manufacturers, and other relevant data. - * - * @remarks - * This component is designed for client-side use only because it relies on the `useParams` hook - * to retrieve the device ID from the URL. It also handles loading and error states. - * - * @param deviceId - The ID of the device to fetch details for. If not provided, it will use the ID from URL params. - * - * @returns The rendered connection details table or an error message if data is unavailable. - * - * @see {@link useParams} for retrieving the device ID from URL parameters. - * @see {@link DeviceResponse} for the structure of the device data. - * @see {@link QUERY} for the GraphQL query used to fetch device details. - * @see {@link InterfaceEdge} and {@link InterfaceNode} for the structure of interface data. - * @see {@link Mac} and {@link MacPort} for the structure of MAC address data. - */ - -type DeviceResponse = { - device: DeviceNode | null; -}; -export function ConnectionDetails({ device }: { device: DeviceNode }) { - const params = useParams(); - const extractMacAddresses = (macports?: MacPort): string => { - if (!Array.isArray(macports?.edges) || macports.edges.length === 0) - return ""; - - return macports.edges - .flatMap((edge) => { - const macs = edge?.node?.macs; - const macList = Array.isArray(macs) ? macs : macs ? [macs] : []; - return macList.map((macObj) => macObj?.mac).filter(Boolean); - }) - .join(", "); - }; - - const extractManufacturers = (macports?: MacPort): string => { - if (!Array.isArray(macports?.edges) || macports.edges.length === 0) - return ""; - - return macports.edges - .flatMap((edge) => { - const macs = edge?.node?.macs; - const macList = Array.isArray(macs) ? macs : macs ? [macs] : []; - return macList - .map((macObj) => macObj?.oui?.organization || "") - .filter(Boolean); - }) - .join(", "); - }; - - if (!device || !device.l1interfaces?.edges?.length) - return

No interface data available.

; - - const edges = device.l1interfaces.edges ?? []; - const interfaces = edges - .map(({ node }: InterfaceEdge) => node) - .filter(Boolean); - - return ( -
-

Connection Details

-
- - - - {[ - "Port", - "VLAN", - "State", - "Days Inactive", - "Speed", - "Duplex", - "Port Label", - "Trunk", - "CDP", - "LLDP", - "Mac Address", - "Manufacturer", - "IP Address", - "DNS Name", - ].map((title) => ( - - ))} - - - - {interfaces.map((iface: InterfaceNode) => ( - - - - - - - - - - - - - {/* Render MAC addresses */} - - {/* Render MAC manufacturers */} - - {/* Placeholders for IP Address and DNS Name */} - {/* These would need to be populated with real data when available */} - - - - ))} - -
{title}
{iface.ifname || "N/A"}{iface.nativevlan ?? "N/A"} - {iface.ifoperstatus == 1 - ? "Active" - : iface.ifoperstatus == 2 - ? "Disabled" - : "N/A"} - {iface.tsIdle ?? "N/A"}{iface.ifspeed ?? "N/A"}{iface.duplex ?? "N/A"}{iface.ifalias || "N/A"}{iface.trunk ? "Trunk" : "-"} - {iface.cdpcachedeviceid ? ( - <> -
{iface.cdpcachedeviceid}
-
{iface.cdpcachedeviceport}
- - ) : ( - "-" - )} -
- {iface.lldpremsysname ? ( - <> -
{iface.lldpremsysname}
-
{iface.lldpremportdesc}
- - ) : ( - "-" - )} -
{extractMacAddresses(iface.macports)}{extractManufacturers(iface.macports)}
-
-
- ); -} +"use client"; +import { useParams } from "next/navigation"; +import { + InterfaceEdge, + InterfaceNode, + Mac, + MacPort, +} from "@/app/types/graphql/GetDeviceInterfaces"; +import { DeviceNode } from "@/app/types/graphql/GetZoneDevices"; +/** + * ConnectionDetails component fetches and displays detailed information about a device's interfaces. + * It includes MAC addresses, manufacturers, and other relevant data. + * + * @remarks + * This component is designed for client-side use only because it relies on the `useParams` hook + * to retrieve the device ID from the URL. It also handles loading and error states. + * + * @param deviceId - The ID of the device to fetch details for. If not provided, it will use the ID from URL params. + * + * @returns The rendered connection details table or an error message if data is unavailable. + * + * @see {@link useParams} for retrieving the device ID from URL parameters. + * @see {@link DeviceResponse} for the structure of the device data. + * @see {@link QUERY} for the GraphQL query used to fetch device details. + * @see {@link InterfaceEdge} and {@link InterfaceNode} for the structure of interface data. + * @see {@link Mac} and {@link MacPort} for the structure of MAC address data. + */ + +export function ConnectionDetails({ device }: { device: DeviceNode }) { + const params = useParams(); + const extractMacAddresses = (macports?: MacPort): string => { + if (!Array.isArray(macports?.edges) || macports.edges.length === 0) + return ""; + + return macports.edges + .flatMap((edge) => { + const macs = edge?.node?.macs; + const macList = Array.isArray(macs) ? macs : macs ? [macs] : []; + return macList.map((macObj) => macObj?.mac).filter(Boolean); + }) + .join(", "); + }; + + const extractManufacturers = (macports?: MacPort): string => { + if (!Array.isArray(macports?.edges) || macports.edges.length === 0) + return ""; + + return macports.edges + .flatMap((edge) => { + const macs = edge?.node?.macs; + const macList = Array.isArray(macs) ? macs : macs ? [macs] : []; + return macList + .map((macObj) => macObj?.oui?.organization || "") + .filter(Boolean); + }) + .join(", "); + }; + + if (!device || !device.l1interfaces?.edges?.length) + return

No interface data available.

; + + const edges = device.l1interfaces.edges ?? []; + const interfaces = edges + .map(({ node }: InterfaceEdge) => node) + .filter(Boolean); + + return ( +
+

Connection Details

+
+ + + + {[ + "Port", + "VLAN", + "State", + "Days Inactive", + "Speed", + "Duplex", + "Port Label", + "Trunk", + "CDP", + "LLDP", + "Mac Address", + "Manufacturer", + "IP Address", + "DNS Name", + ].map((title) => ( + + ))} + + + + {interfaces.map((iface: InterfaceNode) => ( + + + + + + + + + + + + + {/* Render MAC addresses */} + + {/* Render MAC manufacturers */} + + {/* Placeholders for IP Address and DNS Name */} + {/* These would need to be populated with real data when available */} + + + + ))} + +
{title}
{iface.ifname || "N/A"}{iface.nativevlan ?? "N/A"} + {iface.ifoperstatus == 1 + ? "Active" + : iface.ifoperstatus == 2 + ? "Disabled" + : "N/A"} + {iface.tsIdle ?? "N/A"}{iface.ifspeed ?? "N/A"}{iface.duplex ?? "N/A"}{iface.ifalias || "N/A"}{iface.trunk ? "Trunk" : "-"} + {iface.cdpcachedeviceid ? ( + <> +
{iface.cdpcachedeviceid}
+
{iface.cdpcachedeviceport}
+ + ) : ( + "-" + )} +
+ {iface.lldpremsysname ? ( + <> +
{iface.lldpremsysname}
+
{iface.lldpremportdesc}
+ + ) : ( + "-" + )} +
{extractMacAddresses(iface.macports)}{extractManufacturers(iface.macports)}
+
+
+ ); +} diff --git a/frontend/src/app/components/DeviceDetails.module.css b/frontend/src/app/components/DeviceDetails.module.css index 9695bbdf5..7a3d1e625 100644 --- a/frontend/src/app/components/DeviceDetails.module.css +++ b/frontend/src/app/components/DeviceDetails.module.css @@ -1,28 +1,28 @@ -.deviceChartWrapper :global(.topology-chart-container) h2, -.deviceChartWrapper :global(.topology-chart-container) form, -.deviceChartWrapper :global(.topology-chart-container) button, -.deviceChartWrapper :global(.topology-chart-container .topology-instructions) { - display: none; -} - -.deviceChartWrapper :global(.topology-network-canvas) { - width: 100%; - max-width: 800px; - height: auto; - min-height: 300px; - border-width: 0; -} -.deviceChartWrapper :global(.topology-chart-container) { - display: flex; - flex-direction: column-reverse; - max-height: 500px; -} -.deviceChartWrapper{ - max-height: 100vh; - width: fit-content; - min-width: 350px; -} -.tableCustom *{ - border: none ; - border-width: 0 ; -} +.deviceChartWrapper :global(.topology-chart-container) h2, +.deviceChartWrapper :global(.topology-chart-container) form, +.deviceChartWrapper :global(.topology-chart-container) button, +.deviceChartWrapper :global(.topology-chart-container .topology-instructions) { + display: none; +} + +.deviceChartWrapper :global(.topology-network-canvas) { + width: 100%; + max-width: 800px; + height: auto; + min-height: 300px; + border-width: 0; +} +.deviceChartWrapper :global(.topology-chart-container) { + display: flex; + flex-direction: column-reverse; + max-height: 500px; +} +.deviceChartWrapper{ + max-height: 100vh; + width: fit-content; + min-width: 350px; +} +.tableCustom *{ + border: none ; + border-width: 0 ; +} diff --git a/frontend/src/app/components/DeviceDetails.spec.tsx b/frontend/src/app/components/DeviceDetails.spec.tsx new file mode 100644 index 000000000..da9557dd6 --- /dev/null +++ b/frontend/src/app/components/DeviceDetails.spec.tsx @@ -0,0 +1,269 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { DeviceDetails } from "./DeviceDetails"; +import { mockDevice, mockMetricsForHost } from "./__mocks__/deviceMocks"; + +vi.mock("./TopologyChart", () => ({ + TopologyChart: () => ( +
Mocked TopologyChart
+ ), +})); + +// ---------- Helpers ---------- +const openCustomRange = () => { + const button = screen.getByRole("button", { name: /Past 1 day/i }); + fireEvent.click(button); + fireEvent.click(screen.getByText("Custom range")); + + const startInput = screen.getByLabelText(/start date/i) as HTMLInputElement; + const endInput = screen.getByLabelText(/end date/i) as HTMLInputElement; + + return { startInput, endInput }; +}; + +describe("DeviceDetails", () => { + beforeEach(() => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockMetricsForHost), + } as Response) + ) as unknown as typeof fetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ---------- Basic rendering / happy path ---------- + describe("Basic rendering", () => { + it("renders charts and shows device status with fetched metrics", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("System Status")).toBeInTheDocument(); + expect(screen.getByText("CPU Usage (%)")).toBeInTheDocument(); + expect(screen.getByText("Memory Usage (%)")).toBeInTheDocument(); + }); + + expect(screen.getByText("Up")).toBeInTheDocument(); + }); + }); + + // ---------- UI interactions ---------- + describe("UI interactions", () => { + it("toggles time range dropdown", async () => { + render(); + + // initial button text + const button = screen.getByRole("button", { name: /Past 1 day/i }); + fireEvent.click(button); + + // dropdown opens + const option = screen.getByText("Past 1 week"); + expect(option).toBeInTheDocument(); + + // select "Past 1 week" + fireEvent.click(option); + + // button text updates + expect( + screen.getByRole("button", { name: /Past 1 week/i }) + ).toBeInTheDocument(); + + // dropdown closed + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + }); + + // ---------- Custom range validations ---------- + describe("Custom range validations", () => { + beforeEach(() => { + render(); + }); + + it("shows error message if custom range exceeds 180 days", async () => { + const { startInput, endInput } = openCustomRange(); + + // Input range > 180 days + fireEvent.change(startInput, { target: { value: "2025-01-01" } }); + fireEvent.change(endInput, { target: { value: "2025-09-01" } }); + + await waitFor(() => + expect( + screen.getByText("Custom range cannot exceed 180 days.") + ).toBeInTheDocument() + ); + }); + it("accepts valid custom range without showing error", async () => { + const { startInput, endInput } = openCustomRange(); + + fireEvent.change(startInput, { target: { value: "2025-01-01" } }); + fireEvent.change(endInput, { target: { value: "2025-01-15" } }); + + expect( + screen.queryByText("Custom range cannot exceed 180 days.") + ).not.toBeInTheDocument(); + }); + + it("shows error when start date is after end date", async () => { + const { startInput, endInput } = openCustomRange(); + fireEvent.change(endInput, { target: { value: "2025-09-10" } }); + fireEvent.change(startInput, { target: { value: "2025-09-15" } }); + + await waitFor(() => + expect( + screen.getByText("Start date must be before end date.") + ).toBeInTheDocument() + ); + }); + + it("shows error when end date is before start date", async () => { + const { startInput, endInput } = openCustomRange(); + fireEvent.change(startInput, { target: { value: "2025-09-15" } }); + fireEvent.change(endInput, { target: { value: "2025-09-10" } }); + + await waitFor(() => + expect( + screen.getByText("End date must be after start date.") + ).toBeInTheDocument() + ); + }); + + it("errors if start date exceeds 180-day range from end date", async () => { + const { startInput, endInput } = openCustomRange(); + + fireEvent.change(endInput, { target: { value: "2025-08-01" } }); + fireEvent.change(startInput, { target: { value: "2025-01-01" } }); + + await waitFor(() => + expect( + screen.getByText("Custom range cannot exceed 180 days.") + ).toBeInTheDocument() + ); + }); + + it("filters data correctly when custom range is selected", async () => { + const { startInput, endInput } = openCustomRange(); + + // Set a range that includes mockDevice.lastPolled (1693305600 → 2023-08-30 UTC) + fireEvent.change(startInput, { target: { value: "2023-08-29" } }); + fireEvent.change(endInput, { target: { value: "2023-08-31" } }); + + // Wait for chart to render + await waitFor(() => { + expect(screen.getByText("System Status")).toBeInTheDocument(); + expect(screen.getByText("Up")).toBeInTheDocument(); + }); + }); + }); + + // ---------- Network / fetch errors ---------- + describe("Network / fetch errors", () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const renderWithFetch = async (fetchMock: typeof fetch) => { + global.fetch = fetchMock; + render(); + await waitFor(() => + expect( + screen.getByText("Failed to load device metrics.") + ).toBeInTheDocument() + ); + }; + + it("shows error when fetch fails", async () => { + await renderWithFetch( + vi.fn(() => Promise.reject(new Error("Network error"))) as any + ); + }); + + it("logs fetch error to console", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ ok: false, status: 500 }) + ) as unknown as typeof fetch; + + render(); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Error fetching device metrics:"), + expect.any(Error) + ); + }); + }); + + it("throws error for GraphQL errors", async () => { + await renderWithFetch( + vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ errors: [{ message: "Some GraphQL error" }] }), + }) + ) as any + ); + }); + + it("throws error for malformed response", async () => { + await renderWithFetch( + vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ data: { deviceMetrics: null } }), + }) + ) as any + ); + }); + }); + + // ---------- Edge cases / special device states ---------- + describe("Edge cases / special device states", () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shows device status as 'Down' when sysUptime is 0", () => { + const downDevice = { ...mockDevice, sysUptime: 0 }; + render(); + expect(screen.getByText("Down")).toBeInTheDocument(); + }); + + it("handles empty metrics and sets CPU/memory to 0 when values are invalid", async () => { + // Helper to stub fetch with empty metrics + const stubEmptyMetrics = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ data: { deviceMetrics: { edges: [] } } }), + }) + ); + + vi.stubGlobal("fetch", stubEmptyMetrics); + + render(); + + await waitFor(() => { + expect( + screen.getByText(/No uptime data available/i) + ).toBeInTheDocument(); + expect(screen.getByText(/No CPU data available/i)).toBeInTheDocument(); + expect( + screen.getByText(/No memory data available/i) + ).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/app/components/DeviceDetails.tsx b/frontend/src/app/components/DeviceDetails.tsx index f9b4bcfc9..bdb837d66 100644 --- a/frontend/src/app/components/DeviceDetails.tsx +++ b/frontend/src/app/components/DeviceDetails.tsx @@ -1,419 +1,443 @@ -import React, { useEffect, useMemo, useState } from "react"; -import HistoricalChart from "./HistoricalChart"; -import { TopologyChart } from "./TopologyChart"; -import { DeviceNode } from "../types/graphql/GetZoneDevices"; -import styles from "./DeviceDetails.module.css"; -import { formatUptime } from "../utils/time"; -import { formatUnixTimestamp } from "../utils/timeStamp"; -import { truncateLines } from "../utils/stringUtils"; - -/** - * DeviceDetails component displays detailed information about a specific device, - * including its metadata and historical performance charts. - * It fetches device metrics from a GraphQL API and allows users to filter - * the displayed data by predefined time ranges or a custom date range. - * It also includes a topology chart to visualize the device's connections. - * @remarks - * This component is designed for client-side use only because it relies on - * the `useEffect` hook for fetching data and managing state. - * It also uses `useMemo` to optimize rendering of static parts of the UI. - * @param device - The device object containing basic information like hostname and sysName. - * @returns The rendered device details component. - * - * @see {@link DeviceResponse} for the structure of the device data response. - * @see {@link DeviceNode} for the structure of the device data. - * @see {@link HistoricalChart} for the chart component used to display historical data. - * @see {@link TopologyChart} for the topology visualization component. - * @see {@link useState} for managing component state. - * @see {@link useEffect} for fetching data and handling side effects. - * @see {@link useMemo} for optimizing rendering of static UI parts. - */ - -function MetadataRow({ label, value }: { label: string; value: string }) { - return ( - - {label} - {value} - - ); -} - -// Type for device metrics returned from GraphQL -type DeviceData = { - hostname: string; - uptime?: number; - cpuUtilization: number; - memoryUtilization: number; - lastPolled: number; - sysName?: string; - sysDescription?: string; - sysObjectid?: string; -}; - -type DeviceDetailsProps = { - device: DeviceNode; -}; - -const TIME_RANGES = [ - { label: "Past 1 day", value: 1 }, - { label: "Past 1 week", value: 7 }, - { label: "Past 1 month", value: 30 }, - { label: "Past 6 months", value: 180 }, - { label: "Custom range", value: 0 }, -]; - -export function DeviceDetails({ device }: DeviceDetailsProps) { - const [uptimeData, setUptimeData] = useState< - { lastPolled: string; value: number }[] - >([]); - - const [cpuUsageData, setCpuUsageData] = useState< - { lastPolled: string; value: number }[] - >([]); - const [memoryUsageData, setMemoryUsageData] = useState< - { lastPolled: string; value: number }[] - >([]); - const [deviceMetrics, setDeviceMetrics] = useState(null); - - const [selectedRange, setSelectedRange] = useState(1); - const [errorMsg, setErrorMsg] = useState(""); - const [customRange, setCustomRange] = useState<{ - start: string; - end: string; - }>({ start: "", end: "" }); - const [open, setOpen] = useState(false); - const topologyChartMemo = useMemo( - () => ( - - ), - [device] // only re-render if device changes - ); - const metadataTableMemo = useMemo( - () => ( -
- - - - - - 0 - ? "Up" - : "Down" - } - /> - - - - -
-
- ), - [device, deviceMetrics] // only re-render if device or deviceMetrics change - ); - - const query = ` - query DeviceMetrics($hostname: String!) { - deviceMetrics(hostname: $hostname) { - edges { - node { - hostname - uptime - cpuUtilization - memoryUtilization - lastPolled - } - } - } - } - `; - - useEffect(() => { - const ac = new AbortController(); - async function fetchData() { - try { - const res = await fetch( - process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || - "http://localhost:7000/switchmap/api/graphql", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query, - variables: { hostname: device.hostname }, - }), - signal: ac.signal, - } - ); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - const json = await res.json(); - if (json?.errors?.length) { - throw new Error(json.errors[0]?.message || "GraphQL error"); - } - if (!json?.data?.deviceMetrics?.edges) { - throw new Error("Malformed response"); - } - - const hostMetrics: DeviceData[] = json.data.deviceMetrics.edges.map( - ({ node }: { node: DeviceData }) => node - ); - if (hostMetrics.length === 0) { - setUptimeData([]); - setCpuUsageData([]); - setMemoryUsageData([]); - setDeviceMetrics(null); - return; - } - if (hostMetrics.length === 0) return; - - hostMetrics.sort((a, b) => Number(a.lastPolled) - Number(b.lastPolled)); - - setDeviceMetrics(hostMetrics[hostMetrics.length - 1]); - - setUptimeData( - hostMetrics.map((m) => { - const uptime = Number(m.uptime); - return { - lastPolled: new Date(m.lastPolled * 1000).toISOString(), - value: Number.isFinite(uptime) && uptime > 0 ? 1 : 0, - }; - }) - ); - - setCpuUsageData( - hostMetrics.map((m) => { - const cpu = Number.isFinite(Number(m.cpuUtilization)) - ? Number(m.cpuUtilization) - : 0; - return { - lastPolled: new Date(Number(m.lastPolled) * 1000).toISOString(), - value: Math.max(0, Math.min(100, cpu)), - }; - }) - ); - - setMemoryUsageData( - hostMetrics.map((m) => { - const mem = Number.isFinite(Number(m.memoryUtilization)) - ? Number(m.memoryUtilization) - : 0; - return { - lastPolled: new Date(Number(m.lastPolled) * 1000).toISOString(), - value: Math.max(0, Math.min(100, mem)), - }; - }) - ); - } catch (error: any) { - if (error.name === "AbortError") return; // ignore aborted fetch - console.error("Error fetching device metrics:", error); - setErrorMsg("Failed to load device metrics."); - setTimeout(() => setErrorMsg(""), 3000); - } - } - - fetchData(); - return () => ac.abort(); - }, [device.hostname]); - - const filterByRange = (data: { lastPolled: string; value: number }[]) => { - const now = new Date(); - let startDate: Date; - - if (selectedRange === 0 && customRange.start && customRange.end) { - startDate = new Date(customRange.start); - const endDate = new Date(customRange.end); - return data.filter( - (d) => - new Date(d.lastPolled) >= startDate && - new Date(d.lastPolled) <= endDate - ); - } else { - startDate = new Date(); - startDate.setDate(now.getDate() - selectedRange); - return data.filter((d) => new Date(d.lastPolled) >= startDate); - } - }; - - return ( -
- {/* Centralized error alert */} - {errorMsg && ( -
-
- {errorMsg} -
-
- )} -

Device Overview

-
- {topologyChartMemo} - {metadataTableMemo} -
- {/* Time Range Dropdown */} -
-
- - - {open && ( -
- {TIME_RANGES.map((r) => ( - - ))} -
- )} -
- {selectedRange === 0 && ( -
- { - const start = new Date(e.target.value); - const end = customRange.end ? new Date(customRange.end) : null; - if (end && start > end) { - setErrorMsg("Start date must be before end date."); - setTimeout(() => setErrorMsg(""), 3000); - return; - } - if ( - end && - (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) > - 180 - ) { - setErrorMsg("Custom range cannot exceed 180 days."); - setTimeout(() => setErrorMsg(""), 3000); - return; - } - - setCustomRange({ ...customRange, start: e.target.value }); - }} - /> - - to - - { - const start = customRange.start - ? new Date(customRange.start) - : null; - const end = new Date(e.target.value); - if (start && end < start) { - setErrorMsg("End date must be after start date."); - setTimeout(() => setErrorMsg(""), 3000); - return; - } - - if ( - start && - end && - (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) > - 180 - ) { - setErrorMsg("Custom range cannot exceed 180 days."); - setTimeout(() => setErrorMsg(""), 3000); - return; - } - - setCustomRange({ ...customRange, end: e.target.value }); - }} - /> -
- )} -
- -
- (v === 1 ? "Up" : "Down"), - allowDecimals: false, - }} - lineType="stepAfter" - /> - - - -
-
- ); -} +import React, { useEffect, useMemo, useState } from "react"; +import { HistoricalChart } from "./HistoricalChart"; +import { TopologyChart } from "./TopologyChart"; +import { DeviceNode } from "../types/graphql/GetZoneDevices"; +import styles from "./DeviceDetails.module.css"; +import { formatUptime } from "../utils/time"; +import { formatUnixTimestamp } from "../utils/timeStamp"; +import { truncateLines } from "../utils/stringUtils"; + +/** + * DeviceDetails component displays detailed information about a specific device, + * including its metadata and historical performance charts. + * It fetches device metrics from a GraphQL API and allows users to filter + * the displayed data by predefined time ranges or a custom date range. + * It also includes a topology chart to visualize the device's connections. + * @remarks + * This component is designed for client-side use only because it relies on + * the `useEffect` hook for fetching data and managing state. + * It also uses `useMemo` to optimize rendering of static parts of the UI. + * @param device - The device object containing basic information like hostname and sysName. + * @returns The rendered device details component. + * + * @see {@link DeviceResponse} for the structure of the device data response. + * @see {@link DeviceNode} for the structure of the device data. + * @see {@link HistoricalChart} for the chart component used to display historical data. + * @see {@link TopologyChart} for the topology visualization component. + * @see {@link useState} for managing component state. + * @see {@link useEffect} for fetching data and handling side effects. + * @see {@link useMemo} for optimizing rendering of static UI parts. + */ + +function MetadataRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +// Type for device metrics returned from GraphQL +type DeviceData = { + hostname: string; + uptime?: number; + cpuUtilization: number; + memoryUtilization: number; + lastPolled: number; + sysName?: string; + sysDescription?: string; + sysObjectid?: string; +}; + +type DeviceDetailsProps = { + device: DeviceNode; +}; + +const TIME_RANGES = [ + { label: "Past 1 day", value: 1 }, + { label: "Past 1 week", value: 7 }, + { label: "Past 1 month", value: 30 }, + { label: "Past 6 months", value: 180 }, + { label: "Custom range", value: 0 }, +]; + +export function DeviceDetails({ device }: DeviceDetailsProps) { + const [uptimeData, setUptimeData] = useState< + { lastPolled: string; value: number }[] + >([]); + + const [cpuUsageData, setCpuUsageData] = useState< + { lastPolled: string; value: number }[] + >([]); + const [memoryUsageData, setMemoryUsageData] = useState< + { lastPolled: string; value: number }[] + >([]); + const [deviceMetrics, setDeviceMetrics] = useState(null); + + const [selectedRange, setSelectedRange] = useState(1); + const [errorMsg, setErrorMsg] = useState(""); + const [customRange, setCustomRange] = useState<{ + start: string; + end: string; + }>({ start: "", end: "" }); + const [open, setOpen] = useState(false); + const topologyChartMemo = useMemo( + () => ( + + ), + [device] // only re-render if device changes + ); + const metadataTableMemo = useMemo( + () => ( +
+ + + + + + 0 + ? "Up" + : "Down" + } + /> + + + + +
+
+ ), + [device, deviceMetrics] // only re-render if device or deviceMetrics change + ); + + const query = ` + query DeviceMetrics($hostname: String!) { + deviceMetrics(hostname: $hostname) { + edges { + node { + hostname + uptime + cpuUtilization + memoryUtilization + lastPolled + } + } + } + } + `; + + useEffect(() => { + const ac = new AbortController(); + async function fetchData() { + try { + const res = await fetch( + process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || + "http://localhost:7000/switchmap/api/graphql", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query, + variables: { hostname: device.hostname }, + }), + signal: ac.signal, + } + ); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const json = await res.json(); + if (json?.errors?.length) { + throw new Error(json.errors[0]?.message || "GraphQL error"); + } + if (!json?.data?.deviceMetrics?.edges) { + throw new Error("Malformed response"); + } + + const hostMetrics: DeviceData[] = json.data.deviceMetrics.edges.map( + ({ node }: { node: DeviceData }) => node + ); + if (hostMetrics.length === 0) { + setUptimeData([]); + setCpuUsageData([]); + setMemoryUsageData([]); + setDeviceMetrics(null); + return; + } + hostMetrics.sort((a, b) => Number(a.lastPolled) - Number(b.lastPolled)); + setDeviceMetrics(hostMetrics[hostMetrics.length - 1]); + + setUptimeData( + hostMetrics.map((m) => { + const uptime = Number(m.uptime); + return { + lastPolled: new Date(m.lastPolled * 1000).toISOString(), + value: Number.isFinite(uptime) && uptime > 0 ? 1 : 0, + }; + }) + ); + + setCpuUsageData( + hostMetrics.map((m) => { + const cpu = Number.isFinite(Number(m.cpuUtilization)) + ? Number(m.cpuUtilization) + : 0; + return { + lastPolled: new Date(Number(m.lastPolled) * 1000).toISOString(), + value: Math.max(0, Math.min(100, cpu)), + }; + }) + ); + + setMemoryUsageData( + hostMetrics.map((m) => { + const mem = Number.isFinite(Number(m.memoryUtilization)) + ? Number(m.memoryUtilization) + : 0; + return { + lastPolled: new Date(Number(m.lastPolled) * 1000).toISOString(), + value: Math.max(0, Math.min(100, mem)), + }; + }) + ); + } catch (error: any) { + if (error.name === "AbortError") return; // ignore aborted fetch + console.error("Error fetching device metrics:", error); + setErrorMsg("Failed to load device metrics."); + setTimeout(() => setErrorMsg(""), 3000); + } + } + + fetchData(); + return () => ac.abort(); + }, [device.hostname]); + + const filterByRange = (data: { lastPolled: string; value: number }[]) => { + const now = new Date(); + let startDate: Date; + + if (selectedRange === 0 && customRange.start && customRange.end) { + startDate = new Date(customRange.start); + startDate.setHours(0, 0, 0, 0); + const endDate = new Date(customRange.end); + // include entire end day + endDate.setHours(23, 59, 59, 999); + return data.filter( + (d) => + new Date(d.lastPolled) >= startDate && + new Date(d.lastPolled) <= endDate + ); + } else { + startDate = new Date(); + startDate.setDate(now.getDate() - selectedRange); + return data.filter((d) => new Date(d.lastPolled) >= startDate); + } + }; + + return ( +
+ {/* Centralized error alert */} + {errorMsg && ( +
+
+ {errorMsg} +
+
+ )} +

Device Overview

+
+ {topologyChartMemo} + {metadataTableMemo} +
+ {/* Time Range Dropdown */} +
+
+ + + {open && ( +
+ {TIME_RANGES.map((r) => ( + + ))} +
+ )} +
+ {selectedRange === 0 && ( +
+ + +
+ )} +
+ +
+ {filterByRange(uptimeData)?.length ? ( + (v === 1 ? "Up" : "Down"), + allowDecimals: false, + }} + lineType="stepAfter" + /> + ) : ( +
+ No uptime data available +
+ )} + + {filterByRange(cpuUsageData)?.length ? ( + + ) : ( +
+ No CPU data available +
+ )} + + {filterByRange(memoryUsageData)?.length ? ( + + ) : ( +
+ No memory data available +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/components/DeviceOverview.spec.tsx b/frontend/src/app/components/DeviceOverview.spec.tsx new file mode 100644 index 000000000..119519530 --- /dev/null +++ b/frontend/src/app/components/DeviceOverview.spec.tsx @@ -0,0 +1,73 @@ +/// +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { DevicesOverview } from "./DevicesOverview"; +import { mockDevice } from "./__mocks__/deviceMocks"; + +vi.mock("next/link", () => ({ default: ({ children }: any) => children })); + +describe("DevicesOverview", () => { + // ---------- Render States ---------- + it("renders loading state", () => { + render(); + expect(screen.getByText(/loading devices/i)).toBeInTheDocument(); + }); + + it("renders error state", () => { + render(); + expect(screen.getByText(/error loading devices/i)).toBeInTheDocument(); + }); + + it("renders no devices found message", () => { + render(); + expect(screen.getByText(/no devices found/i)).toBeInTheDocument(); + }); + + it("renders table with devices", () => { + render( + + ); + expect(screen.getByText(/devices overview/i)).toBeInTheDocument(); + expect(screen.getByText(/device 1/i)).toBeInTheDocument(); + expect(screen.getByText(/host1/i)).toBeInTheDocument(); + expect(screen.getByText(/1\/1/i)).toBeInTheDocument(); // active/total ports + }); + + // ---------- Interactions ---------- + it("updates global filter input", () => { + render( + + ); + const input = screen.getByPlaceholderText(/search/i); + fireEvent.change(input, { target: { value: "Device 1" } }); + expect((input as HTMLInputElement).value).toBe("Device 1"); + }); + + it("calls sorting handler on Enter and Space key press", () => { + render( + + ); + + const headerCell = screen.getByRole("button", { + name: /sort by device name/i, + }); + + fireEvent.keyDown(headerCell, { key: "Enter" }); + fireEvent.keyDown(headerCell, { key: " " }); + expect(headerCell).toBeInTheDocument(); + }); + + it("calls sorting handler on mouse click", () => { + render( + + ); + + const headerCell = screen.getByRole("button", { + name: /sort by device name/i, + }); + + fireEvent.click(headerCell); + expect(headerCell).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/components/DevicesOverview.tsx b/frontend/src/app/components/DevicesOverview.tsx index 15c504f32..82dbaa39f 100644 --- a/frontend/src/app/components/DevicesOverview.tsx +++ b/frontend/src/app/components/DevicesOverview.tsx @@ -1,260 +1,246 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import { - useReactTable, - getCoreRowModel, - getFilteredRowModel, - getSortedRowModel, - flexRender, - createColumnHelper, - SortingState, -} from "@tanstack/react-table"; -import { DeviceNode, InterfaceEdge } from "@/app/types/graphql/GetZoneDevices"; -import { formatUptime } from "@/app/utils/time"; -import { InterfaceNode } from "@/app/types/graphql/GetDeviceInterfaces"; - -/** * DevicesOverview component fetches and displays a list of devices in a table format. - * It supports sorting and filtering of device data. - * @remarks - * This component is designed for client-side use only because it relies on the `useEffect` - * hook for fetching data and managing state. - * It also uses the `useReactTable` hook from `@tanstack/react-table` - * for table management. - * @returns The rendered component. - * @see Device for the structure of device data. - * @see useEffect for fetching devices from the API. - * @see useState for managing the component state. - * @see useReactTable for table management. - * @see formatUptime for converting uptime from hundredths of seconds to a readable string. - * @see createColumnHelper for creating table columns. - * @see SortingState for managing sorting state in the table. - * @see getCoreRowModel, getFilteredRowModel, getSortedRowModel for table row models. - */ - -interface DevicesOverviewProps { - devices: DeviceNode[]; - loading: boolean; - error: string | null; -} - -interface DeviceRow { - name: string; - hostname: string; - ports: string; - uptime: string; - link: string; -} - -export function DevicesOverview({ - devices, - loading, - error, -}: DevicesOverviewProps) { - const [sorting, setSorting] = useState([]); - const [globalFilter, setGlobalFilter] = useState(""); - - const columnHelper = createColumnHelper(); - - // Prepare table data from devices - const data = useMemo(() => { - return devices.map((device) => { - const interfaces = device.l1interfaces.edges.map( - (e: InterfaceEdge) => e.node - ); - const total = interfaces.length; - const active = interfaces.filter( - (p: InterfaceNode) => p.ifoperstatus === 1 - ).length; - - return { - id: device.id, - name: device.sysName || "-", - hostname: device.hostname || "-", - ports: `${active}/${total}`, - uptime: formatUptime(device.sysUptime), - link: `/devices/${encodeURIComponent( - device.idxDevice ?? device.id - )}?sysName=${encodeURIComponent( - device.sysName ?? device.hostname ?? "" - )}#devices-overview`, - }; - }); - }, [devices]); - - // Table columns definition - const columns = [ - columnHelper.accessor("name", { - header: "Device Name", - cell: (info) => ( - - {info.getValue()} - - ), - }), - columnHelper.accessor("hostname", { - header: "Hostname", - }), - columnHelper.accessor("ports", { - header: "Active Ports", - }), - columnHelper.accessor("uptime", { - header: "Uptime", - }), - ]; - - // Create table instance - const table = useReactTable({ - data, - columns, - state: { - sorting, - globalFilter, - }, - onSortingChange: setSorting, - onGlobalFilterChange: setGlobalFilter, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - }); - - if (loading) return

Loading devices...

; - if (error) return

Error loading devices: {error}

; - if (!devices.length) return

No devices found.

; - - return ( -
-

Devices Overview

- - {/* Global search filter */} - setGlobalFilter(e.target.value)} - placeholder="Search..." - className="mb-4 p-2 border rounded w-full max-w-sm" - /> -

- DEVICES MONITORED BY SWITCHMAP -

-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.length === 0 ? ( - - - - ) : ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - )) - )} - -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - header.column.getToggleSortingHandler()?.(e); - } - }} - className="cursor-pointer px-4 py-2" - tabIndex={0} - role="button" - aria-label={`Sort by ${header.column.columnDef.header}`} - > - {flexRender( - header.column.columnDef.header, - header.getContext() - )} - - - ⯅ - - - ⯆ - - -
- No data -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} -
-
-

- DEVICES NOT MONITORED BY SWITCHMAP -

-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - - - - -
- {flexRender( - header.column.columnDef.header, - header.getContext() - )} -
- No data -
-
-
- ); -} +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { + useReactTable, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + flexRender, + createColumnHelper, + SortingState, +} from "@tanstack/react-table"; +import { DeviceNode, InterfaceEdge } from "@/app/types/graphql/GetZoneDevices"; +import { formatUptime } from "@/app/utils/time"; +import { InterfaceNode } from "@/app/types/graphql/GetDeviceInterfaces"; + +/** * DevicesOverview component fetches and displays a list of devices in a table format. + * It supports sorting and filtering of device data. + * @remarks + * This component is designed for client-side use only because it relies on the `useEffect` + * hook for fetching data and managing state. + * It also uses the `useReactTable` hook from `@tanstack/react-table` + * for table management. + * @returns The rendered component. + * @see Device for the structure of device data. + * @see useEffect for fetching devices from the API. + * @see useState for managing the component state. + * @see useReactTable for table management. + * @see formatUptime for converting uptime from hundredths of seconds to a readable string. + * @see createColumnHelper for creating table columns. + * @see SortingState for managing sorting state in the table. + * @see getCoreRowModel, getFilteredRowModel, getSortedRowModel for table row models. + */ + +interface DevicesOverviewProps { + devices: DeviceNode[]; + loading: boolean; + error: string | null; +} + +interface DeviceRow { + name: string; + hostname: string; + ports: string; + uptime: string; + link: string; +} + +export function DevicesOverview({ + devices, + loading, + error, +}: DevicesOverviewProps) { + const [sorting, setSorting] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + + const columnHelper = createColumnHelper(); + + // Prepare table data from devices + const data = useMemo(() => { + return devices.map((device) => { + const interfaces = device.l1interfaces.edges.map( + (e: InterfaceEdge) => e.node + ); + const total = interfaces.length; + const active = interfaces.filter( + (p: InterfaceNode) => p.ifoperstatus === 1 + ).length; + + return { + id: device.id, + name: device.sysName || "-", + hostname: device.hostname || "-", + ports: `${active}/${total}`, + uptime: formatUptime(device.sysUptime), + link: `/devices/${encodeURIComponent( + device.idxDevice ?? device.id + )}?sysName=${encodeURIComponent( + device.sysName ?? device.hostname ?? "" + )}#devices-overview`, + }; + }); + }, [devices]); + + // Table columns definition + const columns = [ + columnHelper.accessor("name", { + header: "Device Name", + cell: (info) => ( + + {info.getValue()} + + ), + }), + columnHelper.accessor("hostname", { + header: "Hostname", + }), + columnHelper.accessor("ports", { + header: "Active Ports", + }), + columnHelper.accessor("uptime", { + header: "Uptime", + }), + ]; + + // Create table instance + const table = useReactTable({ + data, + columns, + state: { + sorting, + globalFilter, + }, + onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + if (loading) return

Loading devices...

; + if (error) return

Error loading devices: {error}

; + if (!devices.length) return

No devices found.

; + + return ( +
+

Devices Overview

+ + {/* Global search filter */} + setGlobalFilter(e.target.value)} + placeholder="Search..." + className="mb-4 p-2 border rounded w-full max-w-sm" + /> +

+ DEVICES MONITORED BY SWITCHMAP +

+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + header.column.getToggleSortingHandler()?.(e); + } + }} + className="cursor-pointer px-4 py-2" + tabIndex={0} + role="button" + aria-label={`Sort by ${header.column.columnDef.header}`} + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + + ⯅ + + + ⯆ + + +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+

+ DEVICES NOT MONITORED BY SWITCHMAP +

+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + + + + +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ No data +
+
+
+ ); +} diff --git a/frontend/src/app/components/HistoricalChart.spec.tsx b/frontend/src/app/components/HistoricalChart.spec.tsx new file mode 100644 index 000000000..0a3fa39bb --- /dev/null +++ b/frontend/src/app/components/HistoricalChart.spec.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { HistoricalChart } from "./HistoricalChart"; +import { mockData } from "./__mocks__/chartMocks"; + +describe("HistoricalChart", () => { + // ---------- Rendering ---------- + + it("renders chart title", () => { + render(); + expect(screen.getByText("System Status")).toBeInTheDocument(); + }); + + // ---------- Props ---------- + + it("applies color and lineType props", () => { + render( + + ); + expect(screen.getByText("Custom Chart")).toBeInTheDocument(); + }); + + it("applies yAxisConfig and unit correctly", () => { + render( + + ); + expect(screen.getByText("YAxis Chart")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/components/HistoricalChart.tsx b/frontend/src/app/components/HistoricalChart.tsx index 9e3bb38ae..40a97ae06 100644 --- a/frontend/src/app/components/HistoricalChart.tsx +++ b/frontend/src/app/components/HistoricalChart.tsx @@ -1,81 +1,81 @@ -"use client"; -import React from "react"; -import { - LineChart, - Line, - XAxis, - YAxis, - Tooltip, - CartesianGrid, - ResponsiveContainer, -} from "recharts"; - -/** - * HistoricalChart component renders a line chart using the Recharts library. - * It displays historical data points with customizable options for appearance and behavior. - * @remarks - * This component is designed for client-side use only because it relies on - * the Recharts library, which requires access to the DOM. - * @param data - An array of data points to be plotted on the chart. - * @param title - The title of the chart. - * @param color - The color of the line in the chart. Default is "#8884d8". - * @param unit - The unit to display in the tooltip. Default is an empty string. - * @param yAxisConfig - Configuration options for the Y-axis, including domain, ticks, tickFormatter, and allowDecimals. - * @param lineType - The type of line to be drawn. Options include "linear", "monotone", "step", "stepAfter", and "stepBefore". Default is "monotone". - * @returns The rendered HistoricalChart component. - * @see {@link LineChart}, {@link Line}, {@link XAxis}, {@link YAxis}, {@link Tooltip}, {@link CartesianGrid}, {@link ResponsiveContainer} from Recharts for chart rendering. - */ - -type DataPoint = { - lastPolled: string; - value: number; -}; - -interface HistoricalChartProps { - data: DataPoint[]; - title: string; - color?: string; - unit?: string; - yAxisConfig?: { - domain?: [number, number]; - ticks?: number[]; - tickFormatter?: (v: number) => string; - allowDecimals?: boolean; - }; - lineType?: "linear" | "monotone" | "step" | "stepAfter" | "stepBefore"; -} - -function HistoricalChart({ - data, - title, - color = "#8884d8", - unit = "", - yAxisConfig, - lineType = "monotone", -}: HistoricalChartProps) { - return ( -
-

{title}

- - - - - - - `${value}${unit}`} - labelStyle={{ fontWeight: "bold" }} - /> - - - -
- ); -} - -export default HistoricalChart; +"use client"; +import React from "react"; +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + ResponsiveContainer, +} from "recharts"; + +/** + * HistoricalChart component renders a line chart using the Recharts library. + * It displays historical data points with customizable options for appearance and behavior. + * @remarks + * This component is designed for client-side use only because it relies on + * the Recharts library, which requires access to the DOM. + * @param data - An array of data points to be plotted on the chart. + * @param title - The title of the chart. + * @param color - The color of the line in the chart. Default is "#8884d8". + * @param unit - The unit to display in the tooltip. Default is an empty string. + * @param yAxisConfig - Configuration options for the Y-axis, including domain, ticks, tickFormatter, and allowDecimals. + * @param lineType - The type of line to be drawn. Options include "linear", "monotone", "step", "stepAfter", and "stepBefore". Default is "monotone". + * @returns The rendered HistoricalChart component. + * @see {@link LineChart}, {@link Line}, {@link XAxis}, {@link YAxis}, {@link Tooltip}, {@link CartesianGrid}, {@link ResponsiveContainer} from Recharts for chart rendering. + */ + +type DataPoint = { + lastPolled: string; + value: number; +}; + +interface HistoricalChartProps { + data: DataPoint[]; + title: string; + color?: string; + unit?: string; + yAxisConfig?: { + domain?: [number, number]; + ticks?: number[]; + tickFormatter?: (v: number) => string; + allowDecimals?: boolean; + }; + lineType?: "linear" | "monotone" | "step" | "stepAfter" | "stepBefore"; +} + +export function HistoricalChart({ + data, + title, + color = "#8884d8", + unit = "", + yAxisConfig, + lineType = "monotone", +}: HistoricalChartProps) { + return ( +
+

{title}

+ + + + + + + `${value}${unit}`} + labelStyle={{ fontWeight: "bold" }} + /> + + + +
+ ); +} + +export default HistoricalChart; diff --git a/frontend/src/app/components/LineChartWrapper.spec.tsx b/frontend/src/app/components/LineChartWrapper.spec.tsx new file mode 100644 index 000000000..03a42a18b --- /dev/null +++ b/frontend/src/app/components/LineChartWrapper.spec.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { LineChartWrapper } from "./LineChartWrapper"; +import { mockLineData } from "./__mocks__/chartMocks"; + +describe("LineChartWrapper", () => { + // ---------- Rendering ---------- + it("renders chart title", () => { + render( + + ); + + expect(screen.getByText("My Chart")).toBeInTheDocument(); + }); + + // ---------- Props ---------- + it("accepts tooltipFormatter prop", () => { + const tooltipFormatter = vi.fn( + (val: unknown, name: string, props: any): [React.ReactNode, string] => { + return [`${val} units`, name]; + } + ); + + render( + + ); + expect(tooltipFormatter("10", "value", { payload: { value: 10 } })).toEqual( + ["10 units", "value"] + ); + }); + + // ---------- Functions / Inline logic ---------- + it("executes tickFormatter with locale-aware expectations", () => { + const tickFormatter = (t: string) => + new Date(t).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + + const expected0 = new Date(mockLineData[0].time).toLocaleDateString( + undefined, + { month: "short", day: "numeric" } + ); + const expected1 = new Date(mockLineData[1].time).toLocaleDateString( + undefined, + { month: "short", day: "numeric" } + ); + expect(tickFormatter(mockLineData[0].time)).toBe(expected0); + expect(tickFormatter(mockLineData[1].time)).toBe(expected1); + }); +}); diff --git a/frontend/src/app/components/Sidebar.spec.tsx b/frontend/src/app/components/Sidebar.spec.tsx new file mode 100644 index 000000000..733206c0f --- /dev/null +++ b/frontend/src/app/components/Sidebar.spec.tsx @@ -0,0 +1,61 @@ +/// +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { Sidebar } from "./Sidebar"; + +vi.mock("next/link", () => ({ default: ({ children }: any) => children })); + +// Mock ThemeToggle +vi.mock("@/app/theme-toggle", () => ({ + ThemeToggle: () =>
ThemeToggle
, +})); + +describe("Sidebar", () => { + // ---------- Rendering ---------- + + it("renders static sidebar on large screens", () => { + render(); + expect(screen.getByText(/Switchmap-NG/i)).toBeInTheDocument(); + expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); + expect(screen.getByText(/History/i)).toBeInTheDocument(); + expect(screen.getByText(/Settings/i)).toBeInTheDocument(); + expect(screen.getByText(/ThemeToggle/i)).toBeInTheDocument(); + }); + + // ---------- Interactions ---------- + + it("opens slide-in sidebar on hamburger click", () => { + render(); + const button = screen.getByLabelText(/open sidebar/i); + fireEvent.click(button); + + const networkLink = screen.getAllByText(/Network Topology/i)[0]; + const devicesLink = screen.getAllByText(/Devices Overview/i)[0]; + + expect(networkLink).toBeInTheDocument(); + expect(devicesLink).toBeInTheDocument(); + }); + + it("closes sidebar when clicking outside", () => { + render(); + const button = screen.getByLabelText(/open sidebar/i); + fireEvent.click(button); + + fireEvent.mouseDown(document.body); + + expect(screen.queryByLabelText("slide-in sidebar")).toBeNull(); + }); + + it("closes sidebar when clicking outside", () => { + render(); + const button = screen.getByLabelText(/open sidebar/i); + fireEvent.click(button); + + // Click outside + fireEvent.mouseDown(document.body); + + // Slide-in sidebar should be removed + expect(screen.queryByTestId("slide-in-sidebar")).toBeNull(); + }); +}); diff --git a/frontend/src/app/components/Sidebar.tsx b/frontend/src/app/components/Sidebar.tsx index 66d66a944..ecfa348de 100644 --- a/frontend/src/app/components/Sidebar.tsx +++ b/frontend/src/app/components/Sidebar.tsx @@ -1,135 +1,136 @@ -"use client"; - -import React, { useState, useRef, useEffect } from "react"; -import Link from "next/link"; -import { FiLayout, FiClock, FiSettings } from "react-icons/fi"; -import { RxHamburgerMenu } from "react-icons/rx"; -import { ThemeToggle } from "@/app/theme-toggle"; -/** - * Sidebar component provides navigation links and a theme toggle button. - * It supports both large screens (desktop) and small screens (mobile). - * It includes a hamburger menu for mobile view and a slide-in sidebar. - * - * @remarks - * This component is designed for client-side use only because it relies on - * the `useState` and `useEffect` hooks for managing state and handling events. - * It also includes responsive design features to adapt to different screen sizes. - * The sidebar contains links to the dashboard, history, and settings pages, - * - * @returns The rendered sidebar component. - * - * @see {@link ThemeToggle} for the theme switching functionality. - * @see {@link Link} for navigation links. - * @see {@link useState} for managing the open/close state of the sidebar. - * @see {@link useEffect} for handling side effects like closing the sidebar on outside clicks. - * @see {@link FiLayout}, {@link FiClock}, {@link FiSettings}, {@link RxHamburgerMenu} for the icons used in the sidebar. - * - */ - -export function Sidebar() { - const [open, setOpen] = useState(false); - const sidebarRef = useRef(null); - - // Close sidebar on outside click - useEffect(() => { - const handleClickOutside = (e: MouseEvent): void => { - const target = e.target as Node; - if (sidebarRef.current && !sidebarRef.current.contains(target)) { - setOpen(false); - } - }; - - if (open) { - document.addEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [open]); - - // Sidebar content - const sidebarContent = ( - - ); - - return ( - <> - {/* Hamburger button */} - - - {/* Static sidebar for large screens */} - - - {/* Slide-in sidebar for small/medium screens */} - {open && ( - <> -
- - - )} - - ); -} +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import Link from "next/link"; +import { FiLayout, FiClock, FiSettings } from "react-icons/fi"; +import { RxHamburgerMenu } from "react-icons/rx"; +import { ThemeToggle } from "@/app/theme-toggle"; +/** + * Sidebar component provides navigation links and a theme toggle button. + * It supports both large screens (desktop) and small screens (mobile). + * It includes a hamburger menu for mobile view and a slide-in sidebar. + * + * @remarks + * This component is designed for client-side use only because it relies on + * the `useState` and `useEffect` hooks for managing state and handling events. + * It also includes responsive design features to adapt to different screen sizes. + * The sidebar contains links to the dashboard, history, and settings pages, + * + * @returns The rendered sidebar component. + * + * @see {@link ThemeToggle} for the theme switching functionality. + * @see {@link Link} for navigation links. + * @see {@link useState} for managing the open/close state of the sidebar. + * @see {@link useEffect} for handling side effects like closing the sidebar on outside clicks. + * @see {@link FiLayout}, {@link FiClock}, {@link FiSettings}, {@link RxHamburgerMenu} for the icons used in the sidebar. + * + */ + +export function Sidebar() { + const [open, setOpen] = useState(false); + const sidebarRef = useRef(null); + + // Close sidebar on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent): void => { + const target = e.target as Node; + if (sidebarRef.current && !sidebarRef.current.contains(target)) { + setOpen(false); + } + }; + + if (open) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [open]); + + // Sidebar content + const sidebarContent = ( + + ); + + return ( + <> + {/* Hamburger button */} + + + {/* Static sidebar for large screens */} + + + {/* Slide-in sidebar for small/medium screens */} + {open && ( + <> +
+ + + )} + + ); +} diff --git a/frontend/src/app/components/TopologyChart.spec.tsx b/frontend/src/app/components/TopologyChart.spec.tsx new file mode 100644 index 000000000..18222a617 --- /dev/null +++ b/frontend/src/app/components/TopologyChart.spec.tsx @@ -0,0 +1,244 @@ +// ---- Mock next/navigation ---- +const mockRouterPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockRouterPush, replace: vi.fn() }), + useParams: () => ({ id: "1" }), + useSearchParams: () => + new URLSearchParams("sysName=TestDevice&hostname=testhost"), +})); + +// ---- Imports after mocks ---- +import { + render, + screen, + fireEvent, + waitFor, + cleanup, +} from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import React from "react"; +import { TopologyChart } from "./TopologyChart"; +import { mockDevice, mockDeviceLLDP } from "./__mocks__/deviceMocks"; +import { Edge, Network } from "vis-network/standalone/esm/vis-network"; + +// ---- Mock vis-network ---- +vi.mock("vis-network/standalone/esm/vis-network", () => { + const updateMock = vi.fn(); + const removeMock = vi.fn(); + const focusMock = vi.fn(); + const addMock = vi.fn(); + const forEachMock = vi.fn((callback: any) => + [{ id: "1" }, { id: "2" }].forEach(callback) + ); + + const DataSetMock = vi.fn((data) => ({ + get: vi.fn(() => data), + add: vi.fn(), + clear: vi.fn(), + update: updateMock, + current: data || [], + forEach: vi.fn(), + })); + + const NetworkMock = vi.fn(() => ({ + fit: vi.fn(), + moveTo: vi.fn(), + on: vi.fn(), + focus: focusMock, + unselectAll: vi.fn(), + destroy: vi.fn(), + nodes: [], + edges: [], + nodesData: { add: addMock, update: updateMock, forEach: forEachMock }, + edgesData: { update: updateMock, forEach: forEachMock }, + getSelectedEdges: vi.fn(() => []), + })); + + return { Network: NetworkMock, DataSet: DataSetMock, removeMock }; +}); + +// ---- Test Suite: TopologyChart ---- +describe("TopologyChart", () => { + afterEach(() => { + vi.resetAllMocks(); + cleanup(); + }); + + it("renders loading state", () => { + render(); + expect(screen.getByText(/loading topology/i)).toBeInTheDocument(); + }); + + it("renders error state", () => { + render(); + expect(screen.getByText(/error loading topology/i)).toBeInTheDocument(); + }); + + it("renders graph with devices", () => { + render( + + ); + expect(screen.getByText(/network topology/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/search device/i)).toBeInTheDocument(); + }); + + it("updates search suggestions", () => { + render( + + ); + const input = screen.getByPlaceholderText(/search device/i); + fireEvent.change(input, { target: { value: "Device" } }); + expect(screen.getByText("Device 1")).toBeInTheDocument(); + }); + + it("selects a device from suggestions and updates UI", async () => { + render( + + ); + const input = screen.getByPlaceholderText(/search device/i); + fireEvent.change(input, { target: { value: "Device" } }); + fireEvent.click(await screen.findByText(/Device 1/i)); + expect( + screen.getByText(/Showing results for: Device 1/i) + ).toBeInTheDocument(); + }); + + it("resets graph on reset button click", () => { + render( + + ); + fireEvent.click(screen.getByText(/reset/i)); + expect(screen.getByText(/network topology/i)).toBeInTheDocument(); + }); + + it("renders with multiple devices", () => { + const devices = [ + mockDevice, + { ...mockDevice, id: "2", idxDevice: 2, sysName: "Device 2" }, + ]; + render(); + expect(screen.getByPlaceholderText(/search device/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /reset/i })).toBeInTheDocument(); + }); + + it("shows no suggestions for unmatched search", () => { + render( + + ); + fireEvent.change(screen.getByPlaceholderText(/search device/i), { + target: { value: "Unknown" }, + }); + expect(screen.queryByText("Device 1")).not.toBeInTheDocument(); + }); + + it("does not crash with undefined devices prop", () => { + render( + + ); + expect(screen.getByText(/network topology/i)).toBeInTheDocument(); + }); + + // ---- Test Suite: TopologyChart additional interactions ---- + describe("TopologyChart additional interactions", () => { + let mockInstance: any; + let removeSpy: ReturnType; + + beforeEach(() => { + removeSpy = vi.fn(); + render( + + ); + mockInstance = (Network as any).mock.results[0]?.value; + mockInstance.nodes = [{ title: { remove: removeSpy } }]; + mockInstance.edges = [{ title: { remove: removeSpy } }]; + mockInstance.removeSpy = removeSpy; + }); + + // ---- Node interactions ---- + it("calls router.push on node double-click", () => { + const doubleClickCallback = mockInstance.on.mock.calls.find( + ([e]: [string, Function]) => e === "doubleClick" + )?.[1]; + doubleClickCallback({ nodes: ["Device 1"] }); + expect(mockRouterPush).toHaveBeenCalledWith( + "/devices/1?sysName=Device%201#devices-overview" + ); + }); + + it("updates nodes on selectNode", () => { + const mockUpdate = vi.fn(); + mockInstance.nodesData = { + current: [ + { id: "1", sysName: "Device 1", opacity: 1, color: {}, font: {} }, + { id: "2", sysName: "Device 2", opacity: 1, color: {}, font: {} }, + ], + update: mockUpdate, + forEach: (cb: (node: any) => void) => + mockInstance.nodesData.current.forEach(cb), + }; + + const callbacks: Record void> = {}; + mockInstance.on = vi.fn((event, cb) => { + callbacks[event] = cb; + }); + + mockInstance.on("selectNode", ({ nodes }: { nodes: string[] }) => { + const selected = nodes[0]; + mockInstance.nodesData.current.forEach((node: any) => { + const isSelected = node.id === selected; + mockInstance.nodesData.update({ + id: node.id, + opacity: isSelected ? 1 : 0.6, + color: { border: "#555" }, + font: { color: isSelected ? "black" : "#A9A9A9" }, + }); + }); + }); + + callbacks["selectNode"]({ nodes: ["1"] }); + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ id: "1", opacity: 1 }) + ); + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ id: "2", opacity: 0.6 }) + ); + }); + + it("resets nodes and edges on deselectNode", () => { + const deselectNodeHandler = mockInstance.on.mock.calls.find( + ([e]: [string, Function]) => e === "deselectNode" + )?.[1]; + deselectNodeHandler(); + expect(mockInstance.nodesData.update).toHaveBeenCalled(); + expect(mockInstance.edgesData.update).toHaveBeenCalled(); + }); + + // ---- Edge interactions ---- + it("updates arrow on hoverEdge", () => { + const hoverEdgeHandler = mockInstance.on.mock.calls.find( + ([e]: [string, Function]) => e === "hoverEdge" + )?.[1]; + hoverEdgeHandler({ edge: "1" }); + expect(mockInstance.edgesData.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: "1", + arrows: { to: { enabled: true, scaleFactor: 0.5 } }, + }) + ); + }); + + it("removes arrow on blurEdge", () => { + const blurEdgeHandler = mockInstance.on.mock.calls.find( + ([e]: [string, Function]) => e === "blurEdge" + )?.[1]; + mockInstance.getSelectedEdges.mockReturnValue([]); + blurEdgeHandler({ edge: "1" }); + expect(mockInstance.edgesData.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: "1", + arrows: { to: false }, + }) + ); + }); + }); +}); diff --git a/frontend/src/app/components/TopologyChart.tsx b/frontend/src/app/components/TopologyChart.tsx index 50b52e04d..b0eca12dd 100644 --- a/frontend/src/app/components/TopologyChart.tsx +++ b/frontend/src/app/components/TopologyChart.tsx @@ -1,579 +1,606 @@ -"use client"; - -import { DeviceNode } from "@/app/types/graphql/GetZoneDevices"; -import React, { useState, useEffect, useRef, useMemo } from "react"; -import { - Network, - DataSet, - Node, - Edge, - Options, -} from "vis-network/standalone/esm/vis-network"; -import { useTheme } from "next-themes"; -import { formatUptime } from "@/app/utils/time"; -import { useRouter } from "next/navigation"; -/** - * Renders a network topology chart using vis-network based on the given devices. - * - * @param {TopologyChartProps} props - The properties for the topology chart. - * @param {Device[]} props.devices - Array of device objects representing nodes. - * @param {boolean} props.loading - Loading state flag. - * @param {Error | null} props.error - Error state, if any. - * - * @returns {JSX.Element} A React component rendering the network graph visualization. - */ - -interface TopologyChartProps { - devices: DeviceNode[]; - loading: boolean; - error: string | null; - zoomView?: boolean; - clickToUse?: boolean; -} - -export function TopologyChart({ - devices, - loading, - error, - zoomView, - clickToUse, -}: TopologyChartProps) { - // React state to hold current graph structure: array of nodes and edges - const [graph, setGraph] = useState<{ nodes: Node[]; edges: Edge[] }>({ - nodes: [], - edges: [], - }); - const router = useRouter(); - // State to track the current search input (used for node filtering/highlighting) - const [inputTerm, setInputTerm] = useState(""); - // State to hold the search term for highlighting nodes - const [searchTerm, setSearchTerm] = useState(""); - // State to track the search result - const [searchResult, setSearchResult] = useState(""); - - // Reference to the DOM element where the network will be rendered - const containerRef = useRef(null); - // Reference to the actual vis-network instance (used for calling methods like focus, fit, etc.) - const networkRef = useRef(null); - // DataSets are vis-network's internal reactive data structures for nodes and edges - // These allow to dynamically add, update, or remove nodes/edges without recreating the network - const nodesData = useRef | null>(null); - const edgesData = useRef | null>(null); - // Theme context to determine if dark mode is enabled - const { theme } = useTheme(); - // Stores the original, unmodified graph (used for resets, filtering, etc.) - const initialGraph = React.useRef<{ nodes: Node[]; edges: Edge[] }>({ - nodes: [], - edges: [], - }); - const [suggestions, setSuggestions] = useState([]); - - const [allNodeLabels, setAllNodeLabels] = useState([]); - - // Determine if current theme is dark to set graph colors accordingly. - // Note: vis-network options are initialized once and do not auto-update on theme change. - // To support dynamic theme switching, update network options manually when `theme` changes. - const isDark = theme === "dark"; - const options: Options = useMemo( - () => ({ - clickToUse: clickToUse ?? true, // Use passed clickToUse prop or default to true - layout: { hierarchical: false }, - physics: { - enabled: true, - solver: "barnesHut", - stabilization: { iterations: 100, updateInterval: 25 }, - }, - edges: { - color: isDark ? "#888" : "#BBB", - width: 1, - arrows: { - to: { - enabled: false, // Disable arrows by default - }, - }, - }, - nodes: { - shape: "dot", - size: 15, - color: isDark ? "#4A90E2" : "#1E90FF", - font: { - size: 12, - color: isDark ? "#fff" : "black", - strokeColor: isDark ? "#081028" : "white", - strokeWidth: 2, - }, - }, - interaction: { - hover: true, - tooltipDelay: 100, - dragNodes: true, - zoomView: zoomView ?? true, // Use passed interaction options or default to true - selectConnectedEdges: false, - }, - }), - [isDark] - ); - - useEffect(() => { - /** - * When the `devices` array updates, this effect builds the graph structure - * (nodes and edges) to render a topology network using vis-network. - * - * - Each device becomes a node. - * - If a device has interfaces with a CDP (Cisco Discovery Protocol) or - * LLDP(Link Layer Discovery Protocol) relationship, - * those relationships become edges. - * - * Custom `title` (tooltip) and `idxDevice` are added to nodes for UX features - * like tooltips and click navigation. - */ - // If no devices are available, reset the graph to empty state - if (!devices || devices.length === 0) { - setGraph({ nodes: [], edges: [] }); - return; - } - // Create sets to track unique nodes and edges - // `nodesSet` tracks nodes already in the graph - // `extraNodesSet` tracks nodes that are not in the current zone - // This helps avoid duplicates and manage relationships correctly - const nodesSet = new Set(); - const extraNodesSet = new Set(); - const edgesArray: Edge[] = []; - // Iterate over each device to build nodes and edges - // We use `sysName` as the unique identifier for each device - devices.forEach((device) => { - const sysName = device?.sysName; - if (!sysName) return; - // If the device is already in the current zone, remove it from `extraNodesSet` - // This ensures we only add devices that are not in the current zone to `extraNodesSet` - if (extraNodesSet.has(sysName)) { - extraNodesSet.delete(sysName); - } - nodesSet.add(device.sysName); - - (device.l1interfaces?.edges ?? []).forEach( - ({ node: iface }: { node: any }) => { - const targetCDP = iface?.cdpcachedeviceid; - const portCDP = iface?.cdpcachedeviceport; - const targetLLDP = iface?.lldpcachedeviceid; - const portLLDP = iface?.lldpcachedeviceport; - // Create edges for CDP or LLDP relationships - if (targetCDP) { - if (!nodesSet.has(targetCDP)) { - extraNodesSet.add(targetCDP); - } - edgesArray.push({ - from: sysName, - to: targetCDP, - label: "", - title: portCDP, - color: "#BBBBBB", - } as Edge); - } else if (targetLLDP) { - if (!nodesSet.has(targetLLDP)) { - extraNodesSet.add(targetLLDP); - } - edgesArray.push({ - from: sysName, - to: targetLLDP, - label: "", - title: portLLDP, - color: "#BBBBBB", - } as Edge); - } - } - ); - }); - function htmlTitle(html: string): HTMLElement { - const container = document.createElement("div"); - container.innerHTML = html; - return container; - } - - // Create nodes array from devices - // Each node has an `id`, `label`, `color`, and custom `title - const nodesArray: Node[] = devices.map((device) => ({ - id: device.sysName ?? "", - label: device.sysName ?? device.idxDevice?.toString() ?? "", - color: "#1E90FF", - idxDevice: device.idxDevice?.toString(), // custom field for navigation - title: htmlTitle( - ` -
-
- ${device.sysName ?? "Unknown"}
- Hostname: ${device.hostname ?? "N/A"} -
-
- ${ - typeof device.sysUptime === "number" && device.sysUptime > 0 - ? "🟢" - : "🔴" - } -
-
-
- ${formatUptime(device.sysUptime) ?? "N/A"} - Uptime -
- `.trim() - ), - })); - // Add extra nodes that are not in the current zone - // These nodes are added with a different color and a tooltip - // indicating they are not in the current zone - extraNodesSet.forEach((sysName) => { - nodesArray.push({ - id: sysName, - label: sysName, - color: "#383e44ff", - }); - }); - // Clean up DOM elements in node titles - initialGraph.current?.nodes?.forEach((node) => { - if ( - node.title instanceof HTMLElement && - typeof node.title.remove === "function" - ) { - node.title.remove(); - } - }); - // Clean up DOM elements in edge titles - initialGraph.current?.edges?.forEach((edge) => { - if ( - edge.title instanceof HTMLElement && - typeof edge.title.remove === "function" - ) { - edge.title.remove(); - } - }); - - // Set the new graph - initialGraph.current = { nodes: nodesArray, edges: edgesArray }; - setGraph({ nodes: nodesArray, edges: edgesArray }); - - // (Lines 255–262 have been removed; the fit()/moveTo() reset now lives - // in the Network-creation useEffect so it always runs on a fresh instance.) - }, [devices]); - - useEffect(() => { - // If no graph data is available, do not render the network - if (!containerRef.current || graph.nodes.length === 0) return; - nodesData.current = new DataSet(graph.nodes); - edgesData.current = new DataSet(graph.edges); - networkRef.current = new Network( - containerRef.current, - { - nodes: nodesData.current, - edges: edgesData.current, - }, - options - ); - - // Double-click event to navigate to device details - // When a node is double-clicked, it navigates to the device details page - networkRef.current.on("doubleClick", (params) => { - if (params.nodes.length === 1) { - const nodeId = params.nodes[0]; - const nodeData = nodesData.current?.get(nodeId); - const node = Array.isArray(nodeData) ? nodeData[0] : nodeData; - - // Only navigate if idxDevice exists - if ((node as any)?.idxDevice) { - const idxDevice = (node as any).idxDevice; - const sysName = (node as any)?.label ?? ""; - const url = `/devices/${encodeURIComponent( - idxDevice - )}?sysName=${encodeURIComponent(sysName)}#devices-overview`; - router.push(url); - } - } - }); - - // Node selection highlighting - // When a node is selected, it highlights the node and dims others - // It also updates the edges to have a different color - - networkRef.current.on("selectNode", ({ nodes }) => { - const selected = nodes[0]; - if (!nodesData.current || !edgesData.current) return; - - nodesData.current.forEach((node) => { - const isSelected = node.id === selected; - nodesData.current!.update({ - id: node.id, - opacity: isSelected ? 1 : 0.6, - color: { - border: isDark ? "#999" : "#555", - }, - font: { - color: isSelected - ? isDark - ? "#fff" - : "black" - : isDark - ? "#aaa" - : "#A9A9A9", - }, - }); - }); - }); - // Reset node selection highlighting - // When a node is deselected, it resets the opacity and color of all nodes - // It also resets the edges color to default - networkRef.current.on("deselectNode", () => { - if (!nodesData.current || !edgesData.current) return; - - nodesData.current.forEach((node) => { - nodesData.current!.update({ - id: node.id, - opacity: 1, - color: { - border: isDark ? "#999" : "#555", - }, - font: { - color: isDark ? "#fff" : "black", - }, - }); - }); - // Reset edges color - initialGraph.current.edges.forEach((originalEdge) => { - edgesData.current!.update({ - id: originalEdge.id, - color: originalEdge.color || (isDark ? "#444" : "#BBBBBB"), // fallback if color missing - }); - }); - }); - // Show arrow on hover - networkRef.current.on("hoverEdge", (params) => { - if (!edgesData.current) return; - - edgesData.current.update({ - id: params.edge, - arrows: { to: { enabled: true, scaleFactor: 0.5 } }, - }); - }); - - networkRef.current.on("blurEdge", (params) => { - if (!edgesData.current || !networkRef.current) return; - - const selectedEdges = networkRef.current.getSelectedEdges(); - - if (!selectedEdges.includes(params.edge)) { - edgesData.current.update({ - id: params.edge, - arrows: { to: false }, - }); - } - }); - - networkRef.current.on("selectEdge", (params) => { - if (!edgesData.current) return; - - edgesData.current.update({ - id: params.edges[0], // handles single selection - arrows: { to: { enabled: true, scaleFactor: 0.5 } }, - }); - }); - - networkRef.current.on("deselectEdge", (params) => { - if (!edgesData.current) return; - - initialGraph.current.edges.forEach((originalEdge) => { - edgesData.current!.update({ - id: originalEdge.id, - color: originalEdge.color || (isDark ? "#444" : "#BBBBBB"), // fallback if color missing - arrows: { to: false }, // reset arrow visibility - }); - }); - }); - const labels = - nodesData.current - ?.get() - ?.map((node: any) => node.label) - .filter(Boolean) || []; - - setAllNodeLabels(labels); - }, [graph, theme]); - - useEffect(() => { - if (!inputTerm || inputTerm.trim() === "") { - setSuggestions([]); - return; - } - - const filtered = allNodeLabels - .filter((label) => label.toLowerCase().includes(inputTerm.toLowerCase())) - .slice(0, 5); // limit to top 5 - - setSuggestions(filtered); - }, [inputTerm, allNodeLabels]); - - // Effect to handle search term changes - // When the search term changes, it highlights the matching node and focuses the network view on it. - // If the node is not found, it logs a warning. - useEffect(() => { - if (!searchTerm || !nodesData.current || !networkRef.current) return; - - const node = nodesData.current.get(searchTerm); - if (!node) { - setSearchResult("No results found for: " + searchTerm); - return; - } else { - // Highlight the node and focus the network - setSearchResult("Showing results for: " + searchTerm); - - networkRef.current.focus(searchTerm, { scale: 1.5, animation: true }); - - nodesData.current.get().forEach((n) => { - nodesData.current!.update({ - id: n.id, - color: { - background: n.id === searchTerm ? "#FF6347" : "#D3D3D3", - border: "#555", - }, - font: { - color: - n.id === searchTerm - ? isDark - ? "#fff" - : "black" - : isDark - ? "#aaa" - : "#A9A9A9", - }, - }); - }); - } - }, [searchTerm]); - - const handleReset = () => { - setInputTerm(""); - setSearchResult(""); - setGraph(initialGraph.current); - - if (!networkRef.current || !nodesData.current || !edgesData.current) return; - - // Clear selection - networkRef.current.unselectAll(); - - // Instead of clear + add, do update - const originalNodes = initialGraph.current.nodes; - const originalEdges = initialGraph.current.edges; - - nodesData.current.clear(); - edgesData.current.clear(); - - nodesData.current.add(originalNodes); - edgesData.current.add(originalEdges); - - // Reset view - networkRef.current.fit(); - }; - - const handleExportImage = () => { - const canvas = containerRef.current?.getElementsByTagName("canvas")[0]; - if (!canvas) return; - - const image = canvas.toDataURL("image/png"); - const link = document.createElement("a"); - link.href = image; - link.download = "topology.png"; - link.click(); - }; - - if (loading) return

Loading topology...

; - if (error) return

Error loading topology: {error}

; - - return ( -
-

- Network Topology -

-
-
-
{ - e.preventDefault(); - setSearchTerm(inputTerm); - setInputTerm(""); - }} - > - { - const value = e.target.value; - setInputTerm(value); - if (value.trim() === "") { - setSuggestions([]); - return; - } - const filtered = allNodeLabels - .filter((label) => - label.toLowerCase().includes(value.toLowerCase()) - ) - .slice(0, 5); - setSuggestions(filtered); - }} - /> - -
- - {suggestions.length > 0 && ( -
    - {suggestions.map((suggestion, index) => ( -
  • { - setSearchTerm(suggestion); - setInputTerm(""); - setSuggestions([]); - }} - className="cursor-pointer px-4 py-2 hover:bg-hover-bg topology-suggestion-item" - > - {suggestion} -
  • - ))} -
- )} -
-
- - -
-
- -

- {searchResult || ""} -

- -
-
- Single-click to select nodes, double-click to open device details. -
-
- ); -} +"use client"; + +import { DeviceNode } from "@/app/types/graphql/GetZoneDevices"; +import React, { useState, useEffect, useRef, useMemo } from "react"; +import { + Network, + DataSet, + Node, + Edge, + Options, +} from "vis-network/standalone/esm/vis-network"; +import { useTheme } from "next-themes"; +import { formatUptime } from "@/app/utils/time"; +import { useRouter } from "next/navigation"; +/** + * Renders a network topology chart using vis-network based on the given devices. + * + * @param {TopologyChartProps} props - The properties for the topology chart. + * @param {DeviceNode[]} props.devices - Array of device objects representing nodes. + * @param {boolean} props.loading - Loading state flag. + * @param {string | null} props.error - Error state, if any. + * + * @returns {JSX.Element} A React component rendering the network graph visualization. + */ + +interface TopologyChartProps { + devices: DeviceNode[]; + loading: boolean; + error: string | null; + zoomView?: boolean; + clickToUse?: boolean; +} + +// vis-network Node plus our custom field for navigation +type VisNode = Node & { idxDevice?: string }; + +export function TopologyChart({ + devices, + loading, + error, + zoomView, + clickToUse, +}: TopologyChartProps) { + // React state to hold current graph structure: array of nodes and edges + const [graph, setGraph] = useState<{ nodes: VisNode[]; edges: Edge[] }>({ + nodes: [], + edges: [], + }); + const router = useRouter(); + // State to track the current search input (used for node filtering/highlighting) + const [inputTerm, setInputTerm] = useState(""); + // State to hold the search term for highlighting nodes + const [searchTerm, setSearchTerm] = useState(""); + // State to track the search result + const [searchResult, setSearchResult] = useState(""); + + // Reference to the DOM element where the network will be rendered + const containerRef = useRef(null); + // Reference to the actual vis-network instance (used for calling methods like focus, fit, etc.) + const networkRef = useRef(null); + // DataSets are vis-network's internal reactive data structures for nodes and edges + // These allow to dynamically add, update, or remove nodes/edges without recreating the network + const nodesData = useRef | null>(null); + const edgesData = useRef | null>(null); + // Theme context to determine if dark mode is enabled + const { theme } = useTheme(); + // Stores the original, unmodified graph (used for resets, filtering, etc.) + const initialGraph = React.useRef<{ nodes: VisNode[]; edges: Edge[] }>({ + nodes: [], + edges: [], + }); + const [suggestions, setSuggestions] = useState([]); + + const [allNodeLabels, setAllNodeLabels] = useState([]); + + // Determine if current theme is dark to set graph colors accordingly. + // Note: vis-network options are initialized once and do not auto-update on theme change. + // To support dynamic theme switching, update network options manually when `theme` changes. + const isDark = theme === "dark"; + const options: Options = useMemo( + () => ({ + clickToUse: clickToUse ?? true, // Use passed clickToUse prop or default to true + layout: { hierarchical: false }, + physics: { + enabled: true, + solver: "barnesHut", + stabilization: { iterations: 100, updateInterval: 25 }, + }, + edges: { + color: isDark ? "#888" : "#BBB", + width: 1, + arrows: { + to: { + enabled: false, // Disable arrows by default + }, + }, + }, + nodes: { + shape: "dot", + size: 15, + color: isDark ? "#4A90E2" : "#1E90FF", + font: { + size: 12, + color: isDark ? "#fff" : "black", + strokeColor: isDark ? "#081028" : "white", + strokeWidth: 2, + }, + }, + interaction: { + hover: true, + tooltipDelay: 100, + dragNodes: true, + zoomView: zoomView ?? true, // Use passed interaction options or default to true + selectConnectedEdges: false, + }, + }), + [isDark, clickToUse, zoomView] + ); + + useEffect(() => { + /** + * When the `devices` array updates, this effect builds the graph structure + * (nodes and edges) to render a topology network using vis-network. + * + * - Each device becomes a node. + * - If a device has interfaces with a CDP (Cisco Discovery Protocol) or + * LLDP(Link Layer Discovery Protocol) relationship, + * those relationships become edges. + * + * Custom `title` (tooltip) and `idxDevice` are added to nodes for UX features + * like tooltips and click navigation. + */ + // If no devices are available, reset the graph to empty state + if (!devices || devices.length === 0) { + setGraph({ nodes: [], edges: [] }); + return; + } + // Create sets to track unique nodes and edges + // `nodesSet` tracks nodes already in the graph + // `extraNodesSet` tracks nodes that are not in the current zone + // This helps avoid duplicates and manage relationships correctly + const nodesSet = new Set(); + const extraNodesSet = new Set(); + const edgesArray: Edge[] = []; + let edgeSeq = 0; + // Iterate over each device to build nodes and edges + // We use `sysName` as the unique identifier for each device + devices.forEach((device) => { + const sysName = device?.sysName; + if (!sysName) return; + // If the device is already in the current zone, remove it from `extraNodesSet` + // This ensures we only add devices that are not in the current zone to `extraNodesSet` + if (extraNodesSet.has(sysName)) { + extraNodesSet.delete(sysName); + } + nodesSet.add(device.sysName); + + (device.l1interfaces?.edges ?? []).forEach( + ({ node: iface }: { node: any }) => { + const targetCDP = iface?.cdpcachedeviceid; + const portCDP = iface?.cdpcachedeviceport; + const targetLLDP = iface?.lldpremsysname; + const portLLDP = iface?.lldpremportdesc; + // Create edges for CDP or LLDP relationships + if (targetCDP) { + const edgeId = `e_${sysName}__${targetCDP}__${ + portCDP ?? "" + }__${edgeSeq++}`; + if (!nodesSet.has(targetCDP)) { + extraNodesSet.add(targetCDP); + } + edgesArray.push({ + from: sysName, + id: edgeId, + to: targetCDP, + label: "", + title: htmlTitle(escapeHtml(String(portCDP ?? ""))), + } as Edge); + } else if (targetLLDP) { + const edgeId = `e_${sysName}__${targetLLDP}__${ + portLLDP ?? "" + }__${edgeSeq++}`; + if (!nodesSet.has(targetLLDP)) { + extraNodesSet.add(targetLLDP); + } + edgesArray.push({ + id: edgeId, + from: sysName, + to: targetLLDP, + label: "", + title: htmlTitle(escapeHtml(String(portLLDP ?? ""))), + } as Edge); + } + } + ); + }); + function escapeHtml(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + function htmlTitle(html: string): HTMLElement { + const container = document.createElement("div"); + container.innerHTML = html; + return container; + } + + // Create nodes array from devices + // Each node has an `id`, `label`, `color`, and custom `title + const nodesArray: VisNode[] = devices + .filter((d) => Boolean(d?.sysName)) + .map( + (device) => + ({ + id: device.sysName!, + label: device.sysName ?? device.idxDevice?.toString() ?? "", + idxDevice: device.idxDevice?.toString(), // custom field for navigation + title: htmlTitle( + ` +
+
+ ${escapeHtml(device.sysName ?? "Unknown")}
+ Hostname: ${escapeHtml(device.hostname ?? "N/A")} +
+
+ ${ + typeof device.sysUptime === "number" && device.sysUptime > 0 + ? "🟢" + : "🔴" + } +
+
+
+ ${ + typeof device.sysUptime === "number" + ? formatUptime(device.sysUptime) + : "N/A" + } + Uptime +
+ `.trim() + ), + } as VisNode) + ); + // Add extra nodes that are not in the current zone + // These nodes are added with a different color and a tooltip + // indicating they are not in the current zone + extraNodesSet.forEach((sysName) => { + nodesArray.push({ + id: sysName, + label: sysName, + color: "#383e44", + }); + }); + // Clean up DOM elements in node titles + initialGraph.current?.nodes?.forEach((node) => { + if ( + node.title instanceof HTMLElement && + typeof node.title.remove === "function" + ) { + node.title.remove(); + } + }); + // Clean up DOM elements in edge titles + initialGraph.current?.edges?.forEach((edge) => { + if ( + edge.title instanceof HTMLElement && + typeof edge.title.remove === "function" + ) { + edge.title.remove(); + } + }); + + // Set the new graph + initialGraph.current = { nodes: nodesArray, edges: edgesArray }; + setGraph({ nodes: nodesArray, edges: edgesArray }); + + // (Lines 255–262 have been removed; the fit()/moveTo() reset now lives + // in the Network-creation useEffect so it always runs on a fresh instance.) + }, [devices]); + + useEffect(() => { + // If no graph data is available, do not render the network + if (!containerRef.current || graph.nodes.length === 0) return; + nodesData.current = new DataSet(graph.nodes); + edgesData.current = new DataSet(graph.edges); + networkRef.current = new Network( + containerRef.current, + { + nodes: nodesData.current, + edges: edgesData.current, + }, + options + ); + + // Double-click event to navigate to device details + // When a node is double-clicked, it navigates to the device details page + networkRef.current.on("doubleClick", (params) => { + if (params.nodes.length === 1) { + const nodeId = params.nodes[0]; + const nodeData = nodesData.current?.get(nodeId); + const node = Array.isArray(nodeData) ? nodeData[0] : nodeData; + + // Only navigate if idxDevice exists + if ((node as any)?.idxDevice) { + const idxDevice = (node as any).idxDevice; + const sysName = (node as any)?.label ?? ""; + const url = `/devices/${encodeURIComponent( + idxDevice + )}?sysName=${encodeURIComponent(sysName)}#devices-overview`; + router.push(url); + } + } + }); + + // Node selection highlighting + networkRef.current.on("selectNode", ({ nodes }) => { + const selected = nodes[0]; + if (!nodesData.current || !edgesData.current) return; + + nodesData.current.forEach((node) => { + const isSelected = node.id === selected; + nodesData.current!.update({ + id: node.id, + opacity: isSelected ? 1 : 0.6, + color: { border: isDark ? "#999" : "#555" }, + font: { + color: isSelected + ? isDark + ? "#fff" + : "black" + : isDark + ? "#aaa" + : "#A9A9A9", + }, + }); + }); + }); + + // Reset node selection highlighting + networkRef.current.on("deselectNode", () => { + if (!nodesData.current || !edgesData.current) return; + + nodesData.current.forEach((node) => { + nodesData.current!.update({ + id: node.id, + opacity: 1, + color: { border: isDark ? "#999" : "#555" }, + font: { color: isDark ? "#fff" : "black" }, + }); + }); + // Reset edges color + initialGraph.current.edges.forEach((originalEdge) => { + edgesData.current!.update({ + id: originalEdge.id!, + color: isDark ? "#444" : "#BBBBBB", + }); + }); + }); + + // Show arrow on hover + networkRef.current.on("hoverEdge", (params) => { + if (!edgesData.current) return; + + edgesData.current.update({ + id: params.edge, + arrows: { to: { enabled: true, scaleFactor: 0.5 } }, + }); + }); + + networkRef.current.on("blurEdge", (params) => { + if (!edgesData.current || !networkRef.current) return; + + const selectedEdges = networkRef.current.getSelectedEdges(); + if (!selectedEdges.includes(params.edge)) { + edgesData.current.update({ + id: params.edge, + arrows: { to: false }, + }); + } + }); + + networkRef.current.on("selectEdge", (params) => { + if (!edgesData.current) return; + + edgesData.current.update({ + id: params.edges[0], + arrows: { to: { enabled: true, scaleFactor: 0.5 } }, + }); + }); + + networkRef.current.on("deselectEdge", (params) => { + if (!edgesData.current) return; + + initialGraph.current.edges.forEach((originalEdge) => { + edgesData.current!.update({ + id: originalEdge.id!, + color: isDark ? "#444" : "#BBBBBB", + arrows: { to: false }, + }); + }); + }); + + const labels = + nodesData.current + ?.get() + ?.map((node: any) => node.label) + .filter(Boolean) || []; + setAllNodeLabels(labels); + + // Cleanup previous Network instance to avoid leaks & duplicate handlers + return () => { + try { + networkRef.current?.destroy(); + } finally { + networkRef.current = null; + nodesData.current = null; + edgesData.current = null; + } + }; + }, [graph, options]); + + useEffect(() => { + if (!inputTerm || inputTerm.trim() === "") { + setSuggestions([]); + return; + } + + const filtered = allNodeLabels + .filter((label) => label.toLowerCase().includes(inputTerm.toLowerCase())) + .slice(0, 5); // limit to top 5 + + setSuggestions(filtered); + }, [inputTerm, allNodeLabels]); + + // Effect to handle search term changes + // When the search term changes, it highlights the matching node and focuses the network view on it. + // If the node is not found, it logs a warning. + useEffect(() => { + if (!searchTerm || !nodesData.current || !networkRef.current) return; + + const node = nodesData.current.get(searchTerm); + if (!node) { + setSearchResult("No results found for: " + searchTerm); + return; + } else { + // Highlight the node and focus the network + setSearchResult("Showing results for: " + searchTerm); + + networkRef.current.focus(searchTerm, { scale: 1.5, animation: true }); + + nodesData.current.get().forEach((n) => { + nodesData.current!.update({ + id: n.id, + color: { + background: n.id === searchTerm ? "#FF6347" : "#D3D3D3", + border: "#555", + }, + font: { + color: + n.id === searchTerm + ? isDark + ? "#fff" + : "black" + : isDark + ? "#aaa" + : "#A9A9A9", + }, + }); + }); + } + }, [searchTerm]); + + const handleReset = () => { + setInputTerm(""); + setSearchResult(""); + setGraph(initialGraph.current); + + if (!networkRef.current || !nodesData.current || !edgesData.current) return; + + // Clear selection + networkRef.current.unselectAll(); + + // Instead of clear + add, do update + const originalNodes = initialGraph.current.nodes; + const originalEdges = initialGraph.current.edges; + + nodesData.current.clear(); + edgesData.current.clear(); + + nodesData.current.add(originalNodes); + edgesData.current.add(originalEdges); + + // Reset view + networkRef.current.fit(); + }; + + const handleExportImage = () => { + const canvas = containerRef.current?.getElementsByTagName("canvas")[0]; + if (!canvas) return; + + const image = canvas.toDataURL("image/png"); + const link = document.createElement("a"); + link.href = image; + link.download = "topology.png"; + link.click(); + }; + + if (loading) return

Loading topology...

; + if (error) return

Error loading topology: {error}

; + + return ( +
+

+ Network Topology +

+
+
+
{ + e.preventDefault(); + setSearchTerm(inputTerm); + setInputTerm(""); + }} + > + { + const value = e.target.value; + setInputTerm(value); + if (value.trim() === "") { + setSuggestions([]); + return; + } + const filtered = allNodeLabels + .filter((label) => + label.toLowerCase().includes(value.toLowerCase()) + ) + .slice(0, 5); + setSuggestions(filtered); + }} + /> + +
+ + {suggestions.length > 0 && ( +
    + {suggestions.map((suggestion) => ( +
  • { + setSearchTerm(suggestion); + setInputTerm(""); + setSuggestions([]); + }} + className="cursor-pointer px-4 py-2 hover:bg-hover-bg topology-suggestion-item" + > + {suggestion} +
  • + ))} +
+ )} +
+
+ + +
+
+ +

+ {searchResult || ""} +

+ +
+
+ Single-click to select nodes, double-click to open device details. +
+
+ ); +} diff --git a/frontend/src/app/components/ZoneDropdown.spec.tsx b/frontend/src/app/components/ZoneDropdown.spec.tsx new file mode 100644 index 000000000..94e105f9d --- /dev/null +++ b/frontend/src/app/components/ZoneDropdown.spec.tsx @@ -0,0 +1,188 @@ +import { + render, + screen, + fireEvent, + waitFor, + act, + within, +} from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ZoneDropdown } from "./ZoneDropdown"; + +describe("ZoneDropdown - Unit Tests", () => { + let originalFetch: any; + + beforeEach(() => { + originalFetch = global.fetch; + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + const renderDropdown = (onChange = vi.fn()) => + render(); + + const mockFetch = (zones: { id: string; name: string; idxZone: string }[]) => + vi.fn( + () => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: { + events: { + edges: [ + { + node: { + zones: { edges: zones.map((z) => ({ node: z })) }, + }, + }, + ], + }, + }, + }), + }) as any + ); + + // ---------- Rendering ---------- + it("renders main button", () => { + renderDropdown(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("toggles open state on button click", () => { + renderDropdown(); + const button = screen.getByRole("button"); + const svg = button.querySelector("svg"); + + expect(svg).not.toHaveClass("rotate-180"); + fireEvent.click(button); + expect(svg).toHaveClass("rotate-180"); + fireEvent.click(button); + expect(svg).not.toHaveClass("rotate-180"); + }); + + // ---------- Fetching / Initial State ---------- + it("selects first zone if selectedZoneId is null", async () => { + const onChange = vi.fn(); + const zones = [{ id: "1", name: "Zone 1", idxZone: "001" }]; + global.fetch = mockFetch(zones); + + renderDropdown(onChange); + await waitFor(() => expect(onChange).toHaveBeenCalledWith("1")); + expect(screen.getByText("Zone 1")).toBeInTheDocument(); + }); + + it("sets loading state while fetching", async () => { + let resolveFetch: any; + global.fetch = vi.fn( + () => + new Promise((res) => { + resolveFetch = res; + }) as any + ); + + renderDropdown(); + expect(screen.getByText(/\(Loading...\)/)).toBeInTheDocument(); + + act(() => + resolveFetch({ ok: true, json: () => ({ data: { events: [] } }) }) + ); + }); + + it("sets error state when fetch fails", async () => { + global.fetch = vi.fn(() => Promise.reject(new Error("Failed"))) as any; + renderDropdown(); + await waitFor(() => + expect(screen.getByText(/(Error)/)).toBeInTheDocument() + ); + }); + + // ---------- Interactions ---------- + it("calls onChange when a zone is clicked", async () => { + const onChange = vi.fn(); + const zones = [{ id: "1", name: "Zone 1", idxZone: "001" }]; + global.fetch = mockFetch(zones); + + renderDropdown(onChange); + await waitFor(() => expect(onChange).toHaveBeenCalledWith("1")); + expect(screen.getByText("Zone 1")).toBeInTheDocument(); + }); + + it("closes dropdown when clicking outside", async () => { + renderDropdown(); + const button = screen.getByRole("button"); + + fireEvent.click(button); // open dropdown + expect(button.querySelector("svg")).toHaveClass("rotate-180"); + + fireEvent.mouseDown(document.body); // click outside + await waitFor(() => + expect(button.querySelector("svg")).not.toHaveClass("rotate-180") + ); + }); + + it("renders dropdown options and 'all' button", async () => { + const onChange = vi.fn(); + const zones = [ + { id: "1", name: "Zone 1", idxZone: "001" }, + { id: "2", name: "Zone 2", idxZone: "002" }, + ]; + global.fetch = mockFetch(zones); + + renderDropdown(onChange); + + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + const dropdownButton = screen.getByRole("button"); + fireEvent.click(dropdownButton); + const menu = await screen.findByTestId("zone-dropdown-menu"); + + for (const zone of zones) { + const zoneButton = await within(menu).findByTestId( + `zone-button-${zone.id}` + ); + expect(zoneButton).toBeInTheDocument(); + } + + fireEvent.click(await within(menu).findByTestId("zone-button-1")); + expect(onChange).toHaveBeenCalledWith("1"); + + fireEvent.click(dropdownButton); // re-open + const updatedMenu = await screen.findByTestId("zone-dropdown-menu"); + fireEvent.click(await within(updatedMenu).findByTestId("zone-button-all")); + expect(onChange).toHaveBeenCalledWith("all"); + }); + + // ---------- Error Handling ---------- + it("handles network error when res.ok is false", async () => { + global.fetch = vi.fn( + () => Promise.resolve({ ok: false, status: 500 }) as any + ); + + renderDropdown(); + fireEvent.click(screen.getByRole("button")); + await waitFor(() => + expect(screen.getByText("Error: Network error: 500")).toBeInTheDocument() + ); + }); + + it("handles GraphQL errors returned in JSON", async () => { + global.fetch = vi.fn( + () => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ errors: [{ message: "GraphQL failed" }] }), + }) as any + ); + + renderDropdown(); + fireEvent.click(screen.getByRole("button")); + await waitFor(() => + expect(screen.getByText("Error: GraphQL failed")).toBeInTheDocument() + ); + }); +}); diff --git a/frontend/src/app/components/ZoneDropdown.tsx b/frontend/src/app/components/ZoneDropdown.tsx index 7f4d2ca9f..f883e107e 100644 --- a/frontend/src/app/components/ZoneDropdown.tsx +++ b/frontend/src/app/components/ZoneDropdown.tsx @@ -1,184 +1,211 @@ -"use client"; - -import { ZoneEdge } from "@/app/types/graphql/GetZoneDevices"; -import { useEffect, useState, useRef } from "react"; -/** - * ZoneDropdown component allows users to select a zone from a dropdown list. - * It fetches the available zones from the API and manages the selected zone state. - * - * @remarks - * This component is designed for client-side use only because it relies on - * the `useEffect` hook for fetching data and managing state. - * It also handles click events outside the dropdown to close it. - * - * @returns The rendered component. - * - * @see {@link Zone} for the structure of a zone. - * @see {@link ZoneDropdownProps} for the props used by the component. - * @see {@link useState} for managing the selected zone state. - * @see {@link useEffect} for fetching zones and handling side effects. - * @see {@link useRef} for managing the dropdown reference to handle outside clicks. - */ - -type Zone = { - name: string; - idxZone: string; - id: string; -}; - -type ZoneDropdownProps = { - selectedZoneId: string | null; - onChange: (zoneId: string) => void; -}; - -export function ZoneDropdown({ selectedZoneId, onChange }: ZoneDropdownProps) { - const [zones, setZones] = useState([]); - const [open, setOpen] = useState(false); - const dropdownRef = useRef(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchZones = async () => { - setLoading(true); - setError(null); - try { - const res = await fetch( - process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || - "http://localhost:7000/switchmap/api/graphql", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: ` - { - events(last: 1) { - edges { - node { - zones { - edges { - node { - id - idxZone - name - } - } - } - } - } - } - } - `, - }), - } - ); - if (!res.ok) { - throw new Error(`Network error: ${res.status}`); - } - const json = await res.json(); - if (json.errors) { - throw new Error(json.errors[0].message); - } - const rawZones = - json?.data?.events?.edges?.[0]?.node?.zones?.edges?.map( - (edge: ZoneEdge) => edge.node - ) ?? []; - setZones(rawZones); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to fetch zones"); - } finally { - setLoading(false); - } - }; - - fetchZones(); - }, []); - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(e.target as Node) - ) { - setOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - // If selectedZoneId is null, pick the first zone (if available) - const selected = - (selectedZoneId && zones.find((z) => z.id === selectedZoneId)) || - (zones.length > 0 ? zones[0] : undefined); - - // If selectedZoneId is null and zones are loaded, notify parent - useEffect(() => { - if (zones.length > 0) { - // Always call onChange with the first zone if selectedZoneId is null or not found in zones - const found = zones.find((z) => z.id === selectedZoneId); - if (!found) { - onChange(zones[0].id); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [zones, selectedZoneId]); - - return ( -
- - - {open && ( - <> - {error && ( -
- Error: {error} -
- )} -
- {zones.map((zone) => ( - - ))} -
- - )} -
- ); -} +"use client"; + +import { ZoneEdge } from "@/app/types/graphql/GetZoneDevices"; +import { useEffect, useState, useRef } from "react"; +/** + * ZoneDropdown component allows users to select a zone from a dropdown list. + * It fetches the available zones from the API and manages the selected zone state. + * + * @remarks + * This component is designed for client-side use only because it relies on + * the `useEffect` hook for fetching data and managing state. + * It also handles click events outside the dropdown to close it. + * + * @returns The rendered component. + * + * @see {@link Zone} for the structure of a zone. + * @see {@link ZoneDropdownProps} for the props used by the component. + * @see {@link useState} for managing the selected zone state. + * @see {@link useEffect} for fetching zones and handling side effects. + * @see {@link useRef} for managing the dropdown reference to handle outside clicks. + */ + +type Zone = { + tsCreated: string; + name: string; + idxZone: string; + id: string; +}; + +type ZoneDropdownProps = { + selectedZoneId: string | null; + onChange: (zoneId: string) => void; +}; + +export function ZoneDropdown({ selectedZoneId, onChange }: ZoneDropdownProps) { + const [zones, setZones] = useState([]); + const [open, setOpen] = useState(false); + const dropdownRef = useRef(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + + const fetchZones = async () => { + setLoading(true); + setError(null); + try { + const res = await fetch( + process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || + "http://localhost:7000/switchmap/api/graphql", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: `{ + events(last: 1) { + edges { + node { + zones { + edges { + node { + tsCreated + id + idxZone + name + } + } + } + } + } + } + }`, + }), + } + ); + + if (!res.ok) throw new Error(`Network error: ${res.status}`); + + const json = await res.json(); + if (json.errors) throw new Error(json.errors[0].message); + + const rawZones = + json?.data?.events?.edges?.[0]?.node?.zones?.edges?.map( + (edge: ZoneEdge) => edge.node + ) ?? []; + + if (active) setZones(rawZones); + } catch (err) { + if (active) + setError( + err instanceof Error ? err.message : "Failed to fetch zones" + ); + } finally { + if (active) setLoading(false); + } + }; + + fetchZones(); + + return () => { + active = false; + }; + }, []); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Determine the selected zone object + const selected = + selectedZoneId === "all" + ? { name: "All", idxZone: "all", id: "all", tsCreated: "" } + : (selectedZoneId && zones.find((z) => z.id === selectedZoneId)) || + (zones.length > 0 ? zones[0] : undefined); + + useEffect(() => { + if (zones.length > 0) { + if (selectedZoneId === "all") return; + const found = zones.find((z) => z.id === selectedZoneId); + if (!found) { + onChange(zones[0].id); + } + } + }, [zones, selectedZoneId]); + + return ( +
+ +

+ {selected?.tsCreated || ""} +

+ + {open && ( + <> + {error && ( +
+ Error: {error} +
+ )} +
+ {zones.map((zone) => ( + + ))} + +
+ + )} +
+ ); +} diff --git a/frontend/src/app/components/__mocks__/chartMocks.ts b/frontend/src/app/components/__mocks__/chartMocks.ts new file mode 100644 index 000000000..cef402b99 --- /dev/null +++ b/frontend/src/app/components/__mocks__/chartMocks.ts @@ -0,0 +1,9 @@ +export const mockData = [ + { lastPolled: "2025-08-01", value: 1 }, + { lastPolled: "2025-08-02", value: 0 }, +]; + +export const mockLineData = [ + { time: "2025-08-01T00:00:00Z", value: 10 }, + { time: "2025-08-02T00:00:00Z", value: 20 }, +]; diff --git a/frontend/src/app/components/__mocks__/deviceMocks.ts b/frontend/src/app/components/__mocks__/deviceMocks.ts new file mode 100644 index 000000000..fd864bb29 --- /dev/null +++ b/frontend/src/app/components/__mocks__/deviceMocks.ts @@ -0,0 +1,102 @@ +import { InterfaceNode } from "@/app/types/graphql/GetDeviceInterfaces"; +import { DeviceNode } from "../../types/graphql/GetZoneDevices"; + +const mockInterface: InterfaceNode = { + idxL1interface: "1", + idxDevice: 1, + ifname: "Gig1/0/1", + nativevlan: 10, + ifoperstatus: 1, + tsIdle: 100, + ifspeed: 1000, + duplex: "full", + ifalias: "uplink", + trunk: true, + cdpcachedeviceid: "dev1", + cdpcachedeviceport: "Gig1/0/2", + cdpcacheplatform: "Cisco 9300", + lldpremportdesc: "Gi1/0/1", + lldpremsysname: "switch1", + lldpremsysdesc: "Cisco 9300", + lldpremsyscapenabled: ["R", "S"], + + + + macports: { + edges: [ + { + node: { + macs: [{ mac: "00:11:22:33:44:55", oui: { organization: "Cisco" } }], + }, + }, + ], + }, + ifinUcastPkts: null, + ifoutUcastPkts: null, + ifinNUcastPkts: null, + ifoutNUcastPkts: null, + ifinOctets: null, + ifoutOctets: null, + ifinErrors: null, + ifoutErrors: null, + ifinDiscards: null, + ifoutDiscards: null +}; + +export const mockDevice: DeviceNode = { + id: "1", + idxDevice: 1, + hostname: "host1", + sysName: "Device 1", + sysDescription: "Test device description", + sysObjectid: "1.3.6.1", + sysUptime: 1000, + lastPolled: 1693305600, + l1interfaces: { + edges: [{ node: mockInterface }], + }, +}; + +export const mockMetricsForHost = { + data: { + deviceMetrics: { + edges: [ + { + node: { + hostname: "host1", + lastPolled:1693305600, + uptime: 120, + cpuUtilization: 55, + memoryUtilization: 40, + }, + }, + { + node: { + hostname: "host1", + lastPolled: Math.floor(Date.now() / 1000) - 3600, + uptime: 0, + cpuUtilization: 20, + memoryUtilization: 30, + }, + }, + ], + }, + }, +}; + +export const mockInterfaceLLDP: InterfaceNode = { + ...mockInterface, + cdpcachedeviceid: null, + cdpcachedeviceport: "", + lldpremportdesc: "Gi1/0/3", + lldpremsysname: "switch2", + lldpremsysdesc: "Cisco 9500", + lldpremsyscapenabled: ["R"], +}; + +export const mockDeviceLLDP: DeviceNode = { + ...mockDevice, + l1interfaces: { + edges: [{ node: mockInterfaceLLDP }], + }, +}; \ No newline at end of file diff --git a/frontend/src/app/devices/[id]/page.spec.tsx b/frontend/src/app/devices/[id]/page.spec.tsx new file mode 100644 index 000000000..5d90b4004 --- /dev/null +++ b/frontend/src/app/devices/[id]/page.spec.tsx @@ -0,0 +1,125 @@ +/// +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Import the component +import DevicePage from "./page"; + +// ---- Mock next/navigation ---- +vi.mock("next/navigation", () => { + return { + useParams: () => ({ id: "1" }), + useSearchParams: () => + new URLSearchParams("sysName=TestDevice&hostname=testhost"), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + }; +}); + +// ---- Mock ThemeToggle ---- +vi.mock("@/app/theme-toggle", () => ({ + ThemeToggle: () =>
ThemeToggle
, +})); + +// ---- Mock DeviceDetails and ConnectionDetails ---- +vi.mock("@/app/components/DeviceDetails", () => ({ + DeviceDetails: ({ device }: any) => ( +
{device.sysName}
+ ), +})); +vi.mock("@/app/components/ConnectionDetails", () => ({ + ConnectionDetails: ({ device }: any) => ( +
{device.hostname}
+ ), +})); + +// ---- Helpers ---- +const mockDevice = { + id: "1", + idxDevice: 1, + sysName: "TestDevice", + hostname: "testhost", +}; + +describe("DevicePage", () => { + beforeEach(() => { + // Mock matchMedia + vi.stubGlobal("matchMedia", (query: string) => { + return { + matches: true, // default to desktop + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + onchange: null, + dispatchEvent: vi.fn(), + }; + }); + + // Mock fetch + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: { device: mockDevice }, + }), + }) + ) as any + ); + }); + + it("renders with sidebar expanded", async () => { + render(); + + // Wait for data load + await waitFor(() => { + expect(screen.getByTestId("device-details")).toHaveTextContent( + "TestDevice" + ); + }); + + expect(screen.getByTestId("theme-toggle")).toBeInTheDocument(); + expect(screen.getByText("Device Overview")).toBeInTheDocument(); + expect(screen.getByText("Connection Details")).toBeInTheDocument(); + }); + + it("switches tabs when clicked", async () => { + render(); + await waitFor(() => screen.getByTestId("device-details")); + + // Click "Connection Details" + fireEvent.click(screen.getByText("Connection Details")); + + await waitFor(() => { + expect(screen.getByTestId("connection-details")).toHaveTextContent( + "testhost" + ); + }); + }); + + it("collapses and expands sidebar", async () => { + render(); + await waitFor(() => screen.getByTestId("device-details")); + + const toggleButton = screen.getByLabelText("Collapse sidebar"); + fireEvent.click(toggleButton); + + expect(screen.getByLabelText("Expand sidebar")).toBeInTheDocument(); + }); + + it("handles fetch error", async () => { + (fetch as any).mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 500, + }) + ); + + render(); + await waitFor(() => { + expect(screen.getByText(/Error:/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/app/devices/[id]/page.tsx b/frontend/src/app/devices/[id]/page.tsx index 4c6574d6e..ca2216906 100644 --- a/frontend/src/app/devices/[id]/page.tsx +++ b/frontend/src/app/devices/[id]/page.tsx @@ -6,6 +6,7 @@ import { ThemeToggle } from "@/app/theme-toggle"; import { ConnectionDetails } from "@/app/components/ConnectionDetails"; import { DeviceDetails } from "@/app/components/DeviceDetails"; import { DeviceNode } from "@/app/types/graphql/GetZoneDevices"; +import { ConnectionCharts } from "@/app/components/ConnectionCharts"; /** * Represents a tab item with label, content, and icon. * @remarks @@ -166,7 +167,15 @@ export default function DevicePage() { }, { label: "Connection Charts", - content:
Connection Charts
, + content: loading ? ( +

Loading...

+ ) : error ? ( +

Error: {error}

+ ) : device ? ( + + ) : ( +

No device data.

+ ), icon: , }, ]; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c2006c8dd..cf3b4a462 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -13,6 +13,7 @@ --color-icon: var(--icon-color); --color-hover-bg: var(--hover-bg); --color-select-bg: var(--select-bg); + --color-border-subtle: var(--border-subtle); } @@ -32,6 +33,7 @@ --edge-color-light: #bbb; --node-color-light: #1e90ff; --font-color-light: black; + --border-subtle: #343B4F; } diff --git a/frontend/src/app/history/page.spec.tsx b/frontend/src/app/history/page.spec.tsx new file mode 100644 index 000000000..d39dcb114 --- /dev/null +++ b/frontend/src/app/history/page.spec.tsx @@ -0,0 +1,316 @@ +/// +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import DeviceHistoryChart from "@/app/history/page"; + +// Mock fetch +beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + zones: { + edges: [ + { + node: { + idxZone: 1, + name: "Zone A", + devices: { + edges: [ + { + node: { + idxDevice: 101, + hostname: "device1", + sysName: "sys-1", + lastPolled: Math.floor(Date.now() / 1000), + }, + }, + ], + }, + }, + }, + ], + }, + }, + }), + }) as any; +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe("DeviceHistoryChart", () => { + it("renders header and description", () => { + render(); + expect( + screen.getByRole("heading", { name: /device history/i }) + ).toBeInTheDocument(); + expect( + screen.getByText(/visualizing the historical movement/i) + ).toBeInTheDocument(); + }); + + it("shows loading state", async () => { + (global.fetch as any).mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + ok: true, + json: async () => ({ data: { zones: { edges: [] } } }), + }), + 100 + ) + ) + ); + + render(); + expect(await screen.findByText(/loading devices/i)).toBeInTheDocument(); + }); + + it("shows results after fetch", async () => { + render(); + + const result = await screen.findByText( + (_, element) => + element?.tagName === "P" && + element.textContent?.includes("Showing results for Hostname: device1") + ); + + expect(result).toBeInTheDocument(); + }); + + it("allows searching for a device", async () => { + render(); + + // Wait for initial results + const initialResult = await screen.findByText( + (_, element) => + element?.tagName === "P" && + element.textContent?.includes("Showing results for Hostname: device1") + ); + expect(initialResult).toBeInTheDocument(); + + // Type in search input + const input = screen.getByPlaceholderText(/search device hostname/i); + fireEvent.change(input, { target: { value: "device1" } }); + + const suggestion = await screen.findByText( + (_, element) => + element?.tagName === "LI" && element.textContent === "device1" + ); + fireEvent.click(suggestion); + + const updatedResult = await screen.findByText( + (_, element) => + element?.tagName === "P" && + element.textContent?.includes("Showing results for Hostname: device1") + ); + + expect(updatedResult).toBeInTheDocument(); + }); + + it("allows changing date range", async () => { + render(); + + // open the menu + const button = await screen.findByRole("button", { name: /past 1 week/i }); + fireEvent.click(button); + + // pick 1 day + fireEvent.click(await screen.findByText(/past 1 day/i)); + expect( + await screen.findByRole("button", { name: /past 1 day/i }) + ).toBeInTheDocument(); + + // pick 1 month + fireEvent.click(screen.getByRole("button", { name: /past 1 day/i })); + fireEvent.click(await screen.findByText(/past 1 month/i)); + expect( + await screen.findByRole("button", { name: /past 1 month/i }) + ).toBeInTheDocument(); + + // pick 6 months + fireEvent.click(screen.getByRole("button", { name: /past 1 month/i })); + fireEvent.click(await screen.findByText(/past 6 months/i)); + expect( + await screen.findByRole("button", { name: /past 6 months/i }) + ).toBeInTheDocument(); + }); + + it("shows an error when custom range exceeds 180 days", async () => { + render(); + + // Open range selector and choose custom + const rangeButton = await screen.findByRole("button", { + name: /past 1 week/i, + }); + fireEvent.click(rangeButton); + fireEvent.click(screen.getByText(/custom/i)); + + const startInput = screen.getByLabelText("custom start date"); + const endInput = screen.getByLabelText("custom end date"); + + // Set end date first + fireEvent.change(endInput, { target: { value: "2025-08-01" } }); + // Then set start date to trigger the >180 days validation + fireEvent.change(startInput, { target: { value: "2025-01-01" } }); + + expect( + await screen.findByText(/custom range cannot exceed 180 days/i) + ).toBeInTheDocument(); + }); + + it("accepts a valid custom date range without showing an error", async () => { + render(); + + const rangeButton = await screen.findByRole("button", { + name: /past 1 week/i, + }); + fireEvent.click(rangeButton); + fireEvent.click(screen.getByText(/custom/i)); + + const startInput = screen.getByLabelText("custom start date"); + const endInput = screen.getByLabelText("custom end date"); + + fireEvent.change(startInput, { target: { value: "2025-01-01" } }); + fireEvent.change(endInput, { target: { value: "2025-01-15" } }); + + expect( + screen.queryByText(/custom range cannot exceed 180 days/i) + ).not.toBeInTheDocument(); + }); + it("shows an error when fetch fails", async () => { + (global.fetch as any).mockRejectedValueOnce(new Error("boom")); + render(); + expect(await screen.findByText(/boom/i)).toBeInTheDocument(); + }); + + it("does not set error when unmounted before fetch fails", async () => { + let rejectFn!: (err: any) => void; + const p = new Promise((_resolve, reject) => { + rejectFn = reject; + }); + (global.fetch as any).mockReturnValueOnce(p as any); + + const { unmount } = render(); + unmount(); + + // trigger rejection after unmount + rejectFn(new Error("late failure")); + await Promise.resolve(); // let microtasks run + + await waitFor(() => { + expect( + screen.queryByText(/error fetching devices/i) + ).not.toBeInTheDocument(); + }); + }); + it("does not search when input is empty", async () => { + render(); + + const form = screen.getByRole("form"); + const input = screen.getByPlaceholderText(/search device hostname/i); + fireEvent.change(input, { target: { value: "" } }); + fireEvent.submit(form); + expect(screen.queryByTestId("suggestions-list")).not.toBeInTheDocument(); + }); + it("submits search term when input is non-empty", async () => { + render(); + + const form = screen.getByRole("form"); + const input = screen.getByPlaceholderText(/search device hostname/i); + + fireEvent.change(input, { target: { value: "device1" } }); + fireEvent.submit(form); + expect(input).toHaveValue(""); + expect(screen.queryByTestId("suggestions-list")).not.toBeInTheDocument(); + }); + it("renders error UI and allows retry", async () => { + (global.fetch as any).mockRejectedValueOnce(new Error("boom")); + + // mock reload + const reloadMock = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, reload: reloadMock }, + }); + + render(); + + expect(await screen.findByText(/error/i)).toBeInTheDocument(); + expect(await screen.findByText(/boom/i)).toBeInTheDocument(); + + const retryBtn = screen.getByRole("button", { name: /retry/i }); + fireEvent.click(retryBtn); + + expect(reloadMock).toHaveBeenCalled(); + }); + + it("shows an error when end date is before start date", async () => { + render(); + + // Open range selector and choose custom + const rangeButton = await screen.findByRole("button", { + name: /past 1 week/i, + }); + fireEvent.click(rangeButton); + fireEvent.click(screen.getByText(/custom/i)); + + const startInput = screen.getByLabelText("custom start date"); + const endInput = screen.getByLabelText("custom end date"); + + // Set start first + fireEvent.change(startInput, { target: { value: "2025-08-01" } }); + // Set end date before start to trigger validation + fireEvent.change(endInput, { target: { value: "2025-01-01" } }); + + expect( + await screen.findByText(/end date must be after start date/i) + ).toBeInTheDocument(); + }); + + it("shows an error when custom range exceeds 180 days", async () => { + render(); + + const rangeButton = await screen.findByRole("button", { + name: /past 1 week/i, + }); + fireEvent.click(rangeButton); + fireEvent.click(screen.getByText(/custom/i)); + + const startInput = screen.getByLabelText("custom start date"); + const endInput = screen.getByLabelText("custom end date"); + + fireEvent.change(startInput, { target: { value: "2025-01-01" } }); + fireEvent.change(endInput, { target: { value: "2025-08-01" } }); // >180 days + + expect( + await screen.findByText(/custom range cannot exceed 180 days/i) + ).toBeInTheDocument(); + }); + it("shows an error when start date is after end date", async () => { + render(); + + // Open range selector and choose custom + const rangeButton = await screen.findByRole("button", { + name: /past 1 week/i, + }); + fireEvent.click(rangeButton); + fireEvent.click(screen.getByText(/custom/i)); + + const startInput = screen.getByLabelText("custom start date"); + const endInput = screen.getByLabelText("custom end date"); + + // Set end date first + fireEvent.change(endInput, { target: { value: "2025-01-01" } }); + // Then set start date after end to trigger the validation + fireEvent.change(startInput, { target: { value: "2025-08-01" } }); + + expect( + await screen.findByText(/start date must be before end date/i) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/history/page.tsx b/frontend/src/app/history/page.tsx index ecb2ea19d..90d6bc849 100644 --- a/frontend/src/app/history/page.tsx +++ b/frontend/src/app/history/page.tsx @@ -84,6 +84,7 @@ function parseDateOnlyLocal(yyyyMmDd: string): Date { export default function DeviceHistoryChart() { const [allDevices, setAllDevices] = useState([]); + const [allDeviceHostnames, setAllDeviceHostnames] = useState([]); const [inputTerm, setInputTerm] = useState(""); const [searchTerm, setSearchTerm] = useState(""); const [suggestions, setSuggestions] = useState([]); @@ -195,6 +196,9 @@ export default function DeviceHistoryChart() { if (!searchTerm && filteredDevices.length > 0) { setSearchTerm(filteredDevices[0].hostname); } + setAllDeviceHostnames( + Array.from(new Set(devicesWithZones.map((d) => d.hostname))) + ); // all devices, unfiltered } } catch (err: any) { if (isMounted) setError(err.message || "Error fetching devices"); @@ -219,11 +223,11 @@ export default function DeviceHistoryChart() { setSuggestions([]); return; } - const filtered = uniqueHostnames + const filtered = allDeviceHostnames .filter((host) => host.toLowerCase().includes(inputTerm.toLowerCase())) .slice(0, 5); setSuggestions(filtered); - }, [inputTerm, uniqueHostnames]); + }, [inputTerm, allDeviceHostnames]); function onSubmit(e: React.FormEvent) { e.preventDefault(); @@ -299,16 +303,6 @@ export default function DeviceHistoryChart() {
); } - if (searchTerm && history.length === 0) { - return ( -
-

- No history found for{" "} - {searchTerm}. -

-
- ); - } return null; }; @@ -322,10 +316,10 @@ export default function DeviceHistoryChart() {
)} -
+
-
+

Device History

Visualizing the historical movement and status changes of devices @@ -336,6 +330,8 @@ export default function DeviceHistoryChart() {

@@ -349,7 +345,10 @@ export default function DeviceHistoryChart() { disabled={loading} /> {suggestions.length > 0 && ( -
    +
      {suggestions.map((suggestion, i) => (
    • { @@ -414,6 +414,7 @@ export default function DeviceHistoryChart() { { diff --git a/frontend/src/app/layout.spec.tsx b/frontend/src/app/layout.spec.tsx new file mode 100644 index 000000000..848b09726 --- /dev/null +++ b/frontend/src/app/layout.spec.tsx @@ -0,0 +1,26 @@ +/// +import React from "react"; +import { render, screen } from "@testing-library/react"; +import RootLayout from "./layout"; + +// --- Mock next/font/google --- +vi.mock("next/font/google", () => ({ + Geist: vi.fn(() => ({ variable: "--font-geist-sans" })), + Geist_Mono: vi.fn(() => ({ variable: "--font-geist-mono" })), +})); + +// --- Mock next-themes --- +vi.mock("next-themes", () => ({ + ThemeProvider: ({ children }: any) =>
      {children}
      , +})); + +describe("RootLayout", () => { + it("renders children inside the layout", () => { + render( + +
      Hello
      +
      + ); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/page.spec.tsx b/frontend/src/app/page.spec.tsx new file mode 100644 index 000000000..95ced8bec --- /dev/null +++ b/frontend/src/app/page.spec.tsx @@ -0,0 +1,256 @@ +/// +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, vi, expect, beforeEach, afterEach } from "vitest"; +import Home from "./page"; + +// ----- Mock Child Components ----- +vi.mock("./components/Sidebar", () => ({ + Sidebar: () =>
      Sidebar
      , +})); + +vi.mock("./components/ZoneDropdown", () => ({ + ZoneDropdown: ({ selectedZoneId, onChange }: any) => ( + + ), +})); + +vi.mock("./components/TopologyChart", () => ({ + TopologyChart: ({ devices, error }: any) => ( +
      + {error ? `Error: ${error}` : `TopologyChart: ${devices.length} devices`} +
      + ), +})); + +vi.mock("./components/DevicesOverview", () => ({ + DevicesOverview: ({ devices, error }: any) => ( +
      + {error ? `Error: ${error}` : `DevicesOverview: ${devices.length} devices`} +
      + ), +})); + +// ----- Mock localStorage ----- +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => (store[key] = value)), + removeItem: vi.fn((key: string) => delete store[key]), + clear: vi.fn(() => (store = {})), + key: vi.fn((index: number) => Object.keys(store)[index] || null), + length: 0, + }; +})() as unknown as Storage; + +// ----- Test Suite ----- +describe("Home page", () => { + let originalFetch: any; + + // ----- Setup & Teardown ----- + beforeEach(() => { + originalFetch = global.fetch; + vi.spyOn(window, "localStorage", "get").mockReturnValue(localStorageMock); + localStorageMock.clear(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + // ----- Rendering ----- + it("renders sidebar and zone dropdown", () => { + render(); + expect(screen.getByTestId("sidebar")).toBeInTheDocument(); + expect(screen.getByTestId("zone-dropdown")).toBeInTheDocument(); + }); + + // ----- Fetch & Deduplication ----- + it("fetches and deduplicates devices when zone is selected", async () => { + const mockZones = [ + { + node: { + devices: { + edges: [ + { node: { hostname: "Device1", idxDevice: 1 } }, + { node: { hostname: "Device1", idxDevice: 2 } }, + { node: { hostname: "Device2", idxDevice: 3 } }, + ], + }, + }, + }, + ]; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ + data: { + events: { + edges: [{ node: { zones: { edges: mockZones } } }], + }, + }, + }), + } as any) + ); + + render(); + const dropdown = screen.getByTestId("zone-dropdown"); + + fireEvent.change(dropdown, { target: { value: "all" } }); + + await waitFor(() => + expect(screen.getByTestId("topology-chart")).toHaveTextContent( + "TopologyChart: 2 devices" + ) + ); + expect(screen.getByTestId("devices-overview")).toHaveTextContent( + "DevicesOverview: 2 devices" + ); + expect(localStorageMock.setItem).toHaveBeenCalledWith("zoneId", "all"); + }); + it("fetches and deduplicates devices for a single zone", async () => { + // Mock fetch response for a single zone + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ + data: { + zone: { + devices: { + edges: [ + { node: { hostname: "DeviceA", idxDevice: 1 } }, + { node: { hostname: "DeviceA", idxDevice: 2 } }, + { node: { hostname: "DeviceB", idxDevice: 3 } }, + ], + }, + }, + }, + }), + } as any) + ); + + render(); + const dropdown = screen.getByTestId("zone-dropdown"); + + fireEvent.change(dropdown, { target: { value: "1" } }); + + await waitFor(() => + expect(screen.getByTestId("topology-chart")).toHaveTextContent( + "TopologyChart: 2 devices" + ) + ); + expect(screen.getByTestId("devices-overview")).toHaveTextContent( + "DevicesOverview: 2 devices" + ); + expect(localStorageMock.setItem).toHaveBeenCalledWith("zoneId", "1"); + }); + + // ----- Error Handling ----- + it("handles network error correctly", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: "Server Error", + json: async () => ({}), + } as any) + ); + + render(); + const dropdown = screen.getByTestId("zone-dropdown"); + fireEvent.change(dropdown, { target: { value: "1" } }); + + await waitFor(() => + expect(screen.getByTestId("topology-chart")).toHaveTextContent( + "Error: Network error: Server Error" + ) + ); + expect(screen.getByTestId("devices-overview")).toHaveTextContent( + "Error: Network error: Server Error" + ); + }); + + it("handles GraphQL errors correctly", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ errors: [{ message: "GraphQL failed" }] }), + } as any) + ); + + render(); + const dropdown = screen.getByTestId("zone-dropdown"); + fireEvent.change(dropdown, { target: { value: "1" } }); + + await waitFor(() => + expect(screen.getByTestId("topology-chart")).toHaveTextContent( + "Error: GraphQL failed" + ) + ); + expect(screen.getByTestId("devices-overview")).toHaveTextContent( + "Error: GraphQL failed" + ); + }); + + it("handles unknown thrown errors correctly", async () => { + global.fetch = vi.fn(() => Promise.reject("Unknown failure")); + + render(); + const dropdown = screen.getByTestId("zone-dropdown"); + fireEvent.change(dropdown, { target: { value: "all" } }); + + await waitFor(() => + expect(screen.getByTestId("topology-chart")).toHaveTextContent( + "Error: Unknown failure" + ) + ); + expect(screen.getByTestId("devices-overview")).toHaveTextContent( + "Error: Unknown failure" + ); + }); + it("handles non-error, non-string failures", async () => { + global.fetch = vi.fn(() => Promise.reject({ some: "object" })); // Not Error, not string + + render(); + const dropdown = screen.getByTestId("zone-dropdown"); + fireEvent.change(dropdown, { target: { value: "all" } }); + + await waitFor(() => + expect(screen.getByTestId("topology-chart")).toHaveTextContent( + "Error: Failed to load devices. Please check your network or try again." + ) + ); + expect(screen.getByTestId("devices-overview")).toHaveTextContent( + "Error: Failed to load devices. Please check your network or try again." + ); + }); + + // ----- Scroll Behavior ----- + it("scrolls to element if hash exists", () => { + window.location.hash = "#devices-overview"; + + const element = document.createElement("div"); + element.id = "devices-overview"; + document.body.appendChild(element); + + element.scrollIntoView = vi.fn(); + + render(); + expect(element.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth" }); + + document.body.removeChild(element); + window.location.hash = ""; + }); +}); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 315de8fe1..ec8bfde2f 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -34,8 +34,6 @@ import { export default function Home() { const [zoneId, setZoneId] = useState(""); const [zoneSelected, setZoneSelected] = useState(false); - - // Add these states for device data const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -75,6 +73,10 @@ export default function Home() { ifoperstatus cdpcachedeviceid cdpcachedeviceport + lldpremportdesc + lldpremsysname + lldpremsysdesc + lldpremsyscapenabled } } } @@ -97,51 +99,102 @@ export default function Home() { try { setLoading(true); setError(null); + + const query = + zoneId === "all" + ? ` + query GetAllZonesDevices { + events(last: 1) { + edges { + node { + zones { + edges { + node { + devices { + edges { + node { + idxDevice + sysObjectid + sysUptime + sysDescription + sysName + hostname + l1interfaces { + edges { + node { + ifoperstatus + cdpcachedeviceid + cdpcachedeviceport + } + } + } + } + } + } + } + } + } + } + } + } + } + ` + : GET_ZONE_DEVICES; + + const variables = zoneId === "all" ? {} : { id: zoneId }; + const res = await fetch( process.env.NEXT_PUBLIC_API_URL || "http://localhost:7000/switchmap/api/graphql", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: GET_ZONE_DEVICES, - variables: { id: zoneId }, - }), + body: JSON.stringify({ query, variables }), } ); - if (!res.ok) - throw new Error(`Network response was not ok: ${res.statusText}`); + if (!res.ok) throw new Error(`Network error: ${res.statusText}`); + const json = await res.json(); - const json: GetZoneDevicesData = await res.json(); if (json.errors) throw new Error(json.errors.map((e: any) => e.message).join(", ")); - const rawDevices = json.data.zone.devices.edges.map( - (edge) => edge.node - ); - setDevices(rawDevices); - } catch (err: unknown) { - if (retryCount < 2) { - setTimeout( - () => fetchDevices(retryCount + 1), - 1000 * (retryCount + 1) - ); - return; - } + let rawDevices: DeviceNode[] = []; - let errorMessage = - "Failed to load devices. Please check your network or try again."; + if (zoneId === "all") { + const devices = + json?.data?.events?.edges?.[0]?.node?.zones?.edges?.flatMap( + (z: any) => z?.node?.devices?.edges?.map((d: any) => d.node) ?? [] + ) ?? []; - if (err instanceof Error) { - console.error("Error fetching devices:", err.message); - errorMessage = err.message; + // Deduplicate by hostname + const seen = new Map(); + devices.forEach((dev: any) => { + if (dev.hostname) seen.set(dev.hostname, dev); + }); + rawDevices = Array.from(seen.values()) as DeviceNode[]; } else { - console.error("Unknown error", err); + const devices = + json?.data?.zone?.devices?.edges?.map((d: any) => d.node) ?? []; + + // Deduplicate by hostname + const seen = new Map(); + devices.forEach((dev: any) => { + if (dev.hostname) seen.set(dev.hostname, dev); + }); + rawDevices = Array.from(seen.values()) as DeviceNode[]; } - + setDevices(rawDevices); + } catch (err: any) { + const message = + err instanceof Error + ? err.message + : typeof err === "string" + ? err + : "Failed to load devices. Please check your network or try again."; + console.error("Error fetching devices:", message); setDevices([]); - setError(errorMessage); + setError(message); } finally { setLoading(false); } diff --git a/frontend/src/app/theme-toggle.spec.tsx b/frontend/src/app/theme-toggle.spec.tsx new file mode 100644 index 000000000..62791e6b0 --- /dev/null +++ b/frontend/src/app/theme-toggle.spec.tsx @@ -0,0 +1,30 @@ +/// +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { ThemeToggle } from "./theme-toggle"; + +const setThemeMock = vi.fn(); + +// Mock next-themes +vi.mock("next-themes", () => ({ + useTheme: () => ({ + resolvedTheme: "light", + setTheme: setThemeMock, + }), +})); + +describe("ThemeToggle", () => { + it("renders without crashing", () => { + render(); + const button = screen.getByRole("button", { name: /toggle theme/i }); + expect(button).toBeInTheDocument(); + }); + + it("toggles theme on click", () => { + render(); + const button = screen.getByRole("button", { name: /toggle theme/i }); + fireEvent.click(button); + expect(setThemeMock).toHaveBeenCalledWith("dark"); + }); +}); diff --git a/frontend/src/app/types/graphql/GetDeviceInterfaces.ts b/frontend/src/app/types/graphql/GetDeviceInterfaces.ts index ef3ec2b96..6e2b833b1 100644 --- a/frontend/src/app/types/graphql/GetDeviceInterfaces.ts +++ b/frontend/src/app/types/graphql/GetDeviceInterfaces.ts @@ -47,8 +47,18 @@ export type InterfaceNode = { ifname: string; nativevlan: number; ifoperstatus: number; - tsIdle: string; + tsIdle: number; ifspeed: number; + ifinUcastPkts: number|null; + ifoutUcastPkts: number|null; + ifinNUcastPkts: number|null; + ifoutNUcastPkts: number|null; + ifinOctets: number|null; + ifoutOctets: number|null; + ifinErrors: number|null; + ifoutErrors: number|null; + ifinDiscards: number|null; + ifoutDiscards: number|null; duplex: string; ifalias?: string | null; trunk: boolean; diff --git a/frontend/src/app/utils/stringUtils.spec.tsx b/frontend/src/app/utils/stringUtils.spec.tsx new file mode 100644 index 000000000..43a138656 --- /dev/null +++ b/frontend/src/app/utils/stringUtils.spec.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { truncateLines } from "./stringUtils"; + +describe("truncateLines", () => { + it("returns 'N/A' for empty string", () => { + expect(truncateLines("")).toBe("N/A"); + }); + + it("returns the same string if shorter than maxLength", () => { + expect(truncateLines("Hello World", { maxLength: 20 })).toBe("Hello World"); + }); + + it("truncates a long string into 2 lines by default", () => { + const str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const result = truncateLines(str); + const lines = result.split("\n"); + expect(lines.length).toBe(2); + expect(result.endsWith("...")).toBe(true); + }); + + it("respects the lines option", () => { + const str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const result = truncateLines(str, { lines: 3 }); + const lines = result.split("\n"); + expect(lines.length).toBe(3); + expect(result.endsWith("...")).toBe(true); + }); + + it("respects the maxLength option", () => { + const str = "abcdefghijklmnopqrstuvwxyz"; + const result = truncateLines(str, { maxLength: 12, lines: 2 }); + expect(result).toBe("abcdef\nghijkl..."); + }); +}); diff --git a/frontend/src/app/utils/time.spec.tsx b/frontend/src/app/utils/time.spec.tsx new file mode 100644 index 000000000..d1a5c610a --- /dev/null +++ b/frontend/src/app/utils/time.spec.tsx @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { formatUptime } from "./time"; + +describe("formatUptime", () => { + it("formats zero hundredths correctly", () => { + expect(formatUptime(0)).toBe("0d 0h 0m 0s"); + }); + + it("formats seconds correctly", () => { + expect(formatUptime(100)).toBe("0d 0h 0m 1s"); // 100 hundredths = 1 second + }); + + it("formats minutes correctly", () => { + expect(formatUptime(6100)).toBe("0d 0h 1m 1s"); // 6100 hundredths = 61 seconds + }); + + it("formats hours correctly", () => { + expect(formatUptime(366000)).toBe("0d 1h 1m 0s"); // 366000 hundredths = 1h 1m + }); + + it("formats days correctly", () => { + expect(formatUptime(9006100)).toBe("1d 1h 1m 1s"); // 9006100 hundredths = 1d 1h 1m 1s + }); +}); diff --git a/frontend/src/app/utils/timeStamp.spec.tsx b/frontend/src/app/utils/timeStamp.spec.tsx new file mode 100644 index 000000000..c71429127 --- /dev/null +++ b/frontend/src/app/utils/timeStamp.spec.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { formatUnixTimestamp } from "./timeStamp"; + +describe("formatUnixTimestamp", () => { + it("returns 'Unknown' for undefined, null, or empty string", () => { + expect(formatUnixTimestamp()).toBe("Unknown"); + expect(formatUnixTimestamp(null)).toBe("Unknown"); + expect(formatUnixTimestamp("")).toBe("Unknown"); + }); + + it("returns 'Unknown' for non-numeric or non-positive values", () => { + expect(formatUnixTimestamp("abc")).toBe("Unknown"); + expect(formatUnixTimestamp(-123)).toBe("Unknown"); + expect(formatUnixTimestamp(0)).toBe("Unknown"); + }); + + it("formats valid numeric timestamps correctly", () => { + const ts = 1693574400; // Example timestamp + const formatted = formatUnixTimestamp(ts); + expect(new Date(ts * 1000).toLocaleString()).toBe(formatted); + }); + + it("formats valid string timestamps correctly", () => { + const tsStr = "1693574400"; + const formatted = formatUnixTimestamp(tsStr); + expect(new Date(Number(tsStr) * 1000).toLocaleString()).toBe(formatted); + }); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c1334095f..8b31e5475 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -12,6 +16,10 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", + "types": [ + "vitest/globals", + "node" + ], "incremental": true, "plugins": [ { @@ -19,9 +27,20 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "**/*.test.ts", + "**/*.test.tsx" + ], + "exclude": [ + "node_modules" + ] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 000000000..7aa3ac661 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import tsconfigPaths from "vite-tsconfig-paths"; +import path from "path"; + +export default defineConfig({ + plugins: [react(), tsconfigPaths()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), // points to src root + "\\.module\\.css$": path.resolve(__dirname, "src/app/components/__mocks__/styleMock.ts"), + "\\.css$": path.resolve(__dirname, "src/app/components/__mocks__/styleMock.ts"), + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: "./vitest.setup.ts", + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + exclude: [ + "vite.config.ts", + "next.config.ts", + "postcss.config.mjs", + ".next/**", + "node_modules/**", + "coverage/**", + "src/app/components/__mocks__/**", + "src/app/components/__mocks__/**/*.ts", + "src/app/types/graphql/**", + "eslint.config.mjs", + "next-env.d.ts", + ], + }, + }, +}); diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts new file mode 100644 index 000000000..4d18f2247 --- /dev/null +++ b/frontend/vitest.setup.ts @@ -0,0 +1,26 @@ +// vitest.setup.ts +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; +import React from 'react'; +// Polyfill ResizeObserver for Recharts +class ResizeObserver { + callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + observe() { + // no-op + } + unobserve() { + // no-op + } + disconnect() { + // no-op + } +} + +(global as any).ResizeObserver = ResizeObserver; + +// Global mocks +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock('next-themes', () => ({ useTheme: () => ({ theme: 'light' }) })); diff --git a/switchmap/server/db/schemas.py b/switchmap/server/db/schemas.py index abe3a53db..5052b5175 100644 --- a/switchmap/server/db/schemas.py +++ b/switchmap/server/db/schemas.py @@ -207,7 +207,28 @@ class Query(graphene.ObjectType): # Results as a single entry filtered by 'id' and as a list device = graphene.relay.Node.Field(Device) - devices = BatchSQLAlchemyConnectionField(Device.connection) + devices = BatchSQLAlchemyConnectionField( + Device.connection, hostname=graphene.String() + ) + + def resolve_devices(root, info, hostname=None, **kwargs): + """Resolve and return devices from the database. + + Args: + root: The root object (not used here). + info: GraphQL resolver info, used to get the query context. + hostname (str, optional): If provided, filters by this hostname. + **kwargs: Additional arguments (ignored). + + Returns: + sqlalchemy.orm.Query: A query object for the matching Device. + """ + query = Device.get_query(info) + + if hostname: + query = query.filter(DeviceModel.hostname == hostname.encode()) + + return query deviceMetrics = BatchSQLAlchemyConnectionField( DeviceMetrics.connection,