From 3649232c6826926acf2514d71d9b365a4bad63ea Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Tue, 4 Aug 2026 15:47:38 -0700 Subject: [PATCH 1/5] Replace container/node views with DataVis NITRO grids, Further UI enhancements Unified the cards/table view toggle for containers and nodes lists into a single DataVis-based grid component with built-in sorting, filtering, and column management. Added expandable resource dashboard for nodes showing live CPU/memory/storage utilization. Updated container list filtering to default to user's own containers (empty filter) while `*` parameter shows all visible containers. Added `/nodes/{id}/stats` endpoint and NodeStats type for live hardware metrics. --- create-a-container/client/package-lock.json | 1127 ++++++++++++++++- create-a-container/client/package.json | 4 +- .../containers/ContainerFilters.tsx | 2 +- .../containers/ContainersDataGrid.tsx | 184 +++ .../src/components/containers/RowActions.tsx | 3 +- .../src/components/nodes/CredentialsBadge.tsx | 23 + .../src/components/nodes/NodeRowActions.tsx | 46 + .../client/src/components/nodes/NodeStats.tsx | 97 ++ .../src/components/nodes/NodesDataGrid.tsx | 130 ++ .../src/components/nodes/NvidiaBadge.tsx | 11 + .../src/components/nodes/ResourceBar.tsx | 53 + create-a-container/client/src/lib/queries.ts | 5 + create-a-container/client/src/lib/types.ts | 35 + .../pages/containers/ContainersListPage.tsx | 211 +-- .../client/src/pages/nodes/NodesListPage.tsx | 207 +-- create-a-container/openapi.v1.yaml | 16 + .../routers/api/v1/containers.js | 55 +- create-a-container/routers/api/v1/nodes.js | 70 + create-a-container/utils/dummy-api.js | 25 + create-a-container/utils/proxmox-api.js | 10 + 20 files changed, 1880 insertions(+), 434 deletions(-) create mode 100644 create-a-container/client/src/components/containers/ContainersDataGrid.tsx create mode 100644 create-a-container/client/src/components/nodes/CredentialsBadge.tsx create mode 100644 create-a-container/client/src/components/nodes/NodeRowActions.tsx create mode 100644 create-a-container/client/src/components/nodes/NodeStats.tsx create mode 100644 create-a-container/client/src/components/nodes/NodesDataGrid.tsx create mode 100644 create-a-container/client/src/components/nodes/NvidiaBadge.tsx create mode 100644 create-a-container/client/src/components/nodes/ResourceBar.tsx diff --git a/create-a-container/client/package-lock.json b/create-a-container/client/package-lock.json index f16d6cea..359ad13d 100644 --- a/create-a-container/client/package-lock.json +++ b/create-a-container/client/package-lock.json @@ -9,8 +9,14 @@ "version": "0.0.0", "dependencies": { "@hookform/resolvers": "^3.9.1", +<<<<<<< Updated upstream "@mieweb/ui": "*", +======= + "@mieweb/datavis": "1.6.0", + "@mieweb/ui": "^0.7.1", +>>>>>>> Stashed changes "@tanstack/react-query": "^5.62.0", + "datavis-ace": "4.1.0", "lucide-react": "^0.460.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -29,6 +35,68 @@ "vite": "^8.0.16" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -122,43 +190,212 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mieweb/datavis": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@mieweb/datavis/-/datavis-1.6.0.tgz", + "integrity": "sha512-KCO8h/BkOUa7cMjS8rdD3CmXIJjKK770BOdbp99/2cpy8+Ij9e+lE6De0hnvWoHAq7pGAkYXelHI+kKrwOVcdA==", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@mieweb/ui": "=0.6.1-dev.169", + "datavis-ace": ">=4.1.0", + "i18next": "^26.3.4", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-i18next": "^17.0.8", + "react-is": "^18.3.1", + "recharts": "^3.9.2" + }, + "peerDependencies": { + "datavis-ace": ">=4.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@mieweb/datavis/node_modules/@mieweb/ui": { + "version": "0.6.1-dev.169", + "resolved": "https://registry.npmjs.org/@mieweb/ui/-/ui-0.6.1-dev.169.tgz", + "integrity": "sha512-YE6vtT/6+hMB7YQROZxPUKm4FVxLTYyLAru+IY8enn9x6GsctCeOA0yjHD6RjQ8oqQDknWS2c3NEfCJ665pGQw==", + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@swc/helpers": "^0.5.19", + "@tanstack/react-virtual": "^3.14.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dompurify": "^3.4.0", + "google-libphonenumber": "^3.2.44", + "highlight.js": "^11.11.1", + "lucide-react": "^0.562.0", + "luxon": "^3.7.2", + "marked": "^17.0.3", + "onnxruntime-web": "1.19.0", + "tailwind-merge": "^2.6.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@kerebron/editor": ">=0.7.9", + "@kerebron/editor-kits": ">=0.7.9", + "@kerebron/wasm": ">=0.7.9", + "@mieweb/datavis": "=1.5.0", + "ag-grid-community": ">=32.0.0", + "ag-grid-react": ">=32.0.0", + "datavis-ace": "=4.1.0", + "js-yaml": ">=4.0.0", + "katex": ">=0.16.0", + "mermaid": ">=11.0.0", + "papaparse": ">=5.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0", + "react-markdown": ">=9.0.0", + "rehype-highlight": ">=7.0.0", + "rehype-katex": ">=7.0.0", + "rehype-sanitize": ">=6.0.0", + "remark-gfm": ">=4.0.0", + "remark-math": ">=6.0.0", + "wavesurfer.js": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@kerebron/editor": { + "optional": true + }, + "@kerebron/editor-kits": { + "optional": true + }, + "@kerebron/wasm": { + "optional": true + }, + "@mieweb/datavis": { + "optional": true + }, + "ag-grid-community": { + "optional": true + }, + "ag-grid-react": { + "optional": true + }, + "datavis-ace": { + "optional": true + }, + "js-yaml": { + "optional": true + }, + "katex": { + "optional": true + }, + "mermaid": { + "optional": true + }, + "papaparse": { + "optional": true + }, + "react": { + "optional": false + }, + "react-dom": { + "optional": false + }, + "react-markdown": { + "optional": true + }, + "rehype-highlight": { + "optional": true + }, + "rehype-katex": { + "optional": true + }, + "rehype-sanitize": { + "optional": true + }, + "remark-gfm": { + "optional": true + }, + "remark-math": { + "optional": true + }, + "wavesurfer.js": { + "optional": true + } + } + }, + "node_modules/@mieweb/datavis/node_modules/@mieweb/ui/node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mieweb/datavis/node_modules/lucide-react": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz", + "integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mieweb/ui": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@mieweb/ui/-/ui-0.6.1.tgz", - "integrity": "sha512-M1zHSukvCs9fTXTa/fZfHKueWOhOv8oomq0Mic9QhXcVMkN6i+6/JQdboEEJRtQYUhxGwNoTFbYIzMpbODvoXg==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@mieweb/ui/-/ui-0.7.1.tgz", + "integrity": "sha512-39FDW2YjTfjH28atU81BUljhH+9YWV49X9ymMuElusNlwV9DakLpr26VsH01X5M1qX6VVheOPnTyv8SzzKAo3g==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { "@swc/helpers": "^0.5.19", + "@tanstack/react-virtual": "^3.14.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.4.0", "google-libphonenumber": "^3.2.44", + "highlight.js": "^11.11.1", "lucide-react": "^0.562.0", "luxon": "^3.7.2", + "marked": "^17.0.3", + "onnxruntime-web": "1.19.0", "tailwind-merge": "^2.6.1" }, "engines": { - "node": ">=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "@esheet/builder": ">=0.0.2", - "@esheet/renderer": ">=0.0.2", - "@ozwell/react": ">=0.1.0", + "@kerebron/editor": ">=0.7.9", + "@kerebron/editor-kits": ">=0.7.9", + "@kerebron/wasm": ">=0.7.9", + "@mieweb/datavis": "=1.6.0", "ag-grid-community": ">=32.0.0", "ag-grid-react": ">=32.0.0", - "datavis-ace": "=4.0.0-PRE.2", + "datavis-ace": "=4.1.0", + "js-yaml": ">=4.0.0", + "katex": ">=0.16.0", + "mermaid": ">=11.0.0", + "papaparse": ">=5.0.0", "react": ">=18.0.0", "react-dom": ">=18.0.0", + "react-markdown": ">=9.0.0", + "rehype-highlight": ">=7.0.0", + "rehype-katex": ">=7.0.0", + "rehype-sanitize": ">=6.0.0", + "remark-gfm": ">=4.0.0", + "remark-math": ">=6.0.0", "wavesurfer.js": ">=7.0.0" }, "peerDependenciesMeta": { - "@esheet/builder": { + "@kerebron/editor": { + "optional": true + }, + "@kerebron/editor-kits": { "optional": true }, - "@esheet/renderer": { + "@kerebron/wasm": { "optional": true }, - "@ozwell/react": { + "@mieweb/datavis": { "optional": true }, "ag-grid-community": { @@ -170,12 +407,42 @@ "datavis-ace": { "optional": true }, + "js-yaml": { + "optional": true + }, + "katex": { + "optional": true + }, + "mermaid": { + "optional": true + }, + "papaparse": { + "optional": true + }, "react": { "optional": false }, "react-dom": { "optional": false }, + "react-markdown": { + "optional": true + }, + "rehype-highlight": { + "optional": true + }, + "rehype-katex": { + "optional": true + }, + "rehype-sanitize": { + "optional": true + }, + "remark-gfm": { + "optional": true + }, + "remark-math": { + "optional": true + }, "wavesurfer.js": { "optional": true } @@ -219,6 +486,89 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "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/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -501,6 +851,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", @@ -874,6 +1236,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -885,11 +1274,73 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -899,7 +1350,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -915,6 +1366,19 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -941,6 +1405,15 @@ } } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -975,11 +1448,179 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/datavis-ace": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/datavis-ace/-/datavis-ace-4.1.0.tgz", + "integrity": "sha512-heaXzdklp3jiqGRoK7YjcklyPT/2aigEEGMSV8Is2Z58oqHf8/rFPmztXjKb5ZQSqn6ygyPtV+u/iwJjTkhoFg==", + "license": "see LICENSE", + "dependencies": { + "bignumber.js": "=9.3.1", + "core-js": "=3.49.0", + "es6-symbol": "=3.1.4", + "json-formatter-js": "git+https://github.com/mieweb/json-formatter-js.git", + "moment": "=2.30.1", + "numeral": "=2.0.6", + "papaparse": "=5.5.4", + "sprintf-js": "=1.1.3", + "underscore": "=1.13.8" + } + }, + "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/detect-libc": { @@ -992,6 +1633,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", @@ -1006,6 +1656,97 @@ "node": ">=10.13.0" } }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1024,6 +1765,12 @@ } } }, + "node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", + "license": "SEE LICENSE IN LICENSE.txt" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1055,6 +1802,77 @@ "dev": true, "license": "ISC" }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1065,6 +1883,11 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/json-formatter-js": { + "version": "2.3.4", + "resolved": "git+ssh://git@github.com/mieweb/json-formatter-js.git#503be5a8368ecdea777f8a7e98bda827391aa6ac", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1326,6 +2149,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lucide-react": { "version": "0.460.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", @@ -1354,6 +2183,27 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -1373,6 +2223,47 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/numeral": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", + "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.19.0.tgz", + "integrity": "sha512-Oo16UIJ/xLOtZDVGcL4bL8EP8MiNFztyBmR3pB14D+cl/UCpOgHHzEk0MADSmYXQ0FgyEegPXtOFcJqhq1YRsw==", + "license": "MIT" + }, + "node_modules/onnxruntime-web": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.19.0.tgz", + "integrity": "sha512-EY2KjvfJ/f5nxiXDii4eD0xe5KIn+mRs55F6z2qomALyG05Qj+kqF81CKqzTa4cXowE9b9aVSqsvEVZkjOI5yA==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.19.0", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1393,6 +2284,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -1422,25 +2319,48 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.8" } }, "node_modules/react-hook-form": { @@ -1459,6 +2379,62 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", @@ -1481,6 +2457,57 @@ } } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -1537,6 +2564,12 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, "node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -1568,6 +2601,12 @@ "url": "https://opencollective.com/webpack" } }, + "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/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1591,11 +2630,17 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1605,13 +2650,49 @@ "node": ">=14.17" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", diff --git a/create-a-container/client/package.json b/create-a-container/client/package.json index 5c5a1d70..1c05c250 100644 --- a/create-a-container/client/package.json +++ b/create-a-container/client/package.json @@ -13,8 +13,10 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.1", - "@mieweb/ui": "latest", + "@mieweb/datavis": "1.6.0", + "@mieweb/ui": "^0.7.1", "@tanstack/react-query": "^5.62.0", + "datavis-ace": "4.1.0", "lucide-react": "^0.460.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/create-a-container/client/src/components/containers/ContainerFilters.tsx b/create-a-container/client/src/components/containers/ContainerFilters.tsx index 31c58ad6..5decf606 100644 --- a/create-a-container/client/src/components/containers/ContainerFilters.tsx +++ b/create-a-container/client/src/components/containers/ContainerFilters.tsx @@ -50,7 +50,7 @@ export function ContainerFilters({
} options={userOptions} selected={selectedUsers} diff --git a/create-a-container/client/src/components/containers/ContainersDataGrid.tsx b/create-a-container/client/src/components/containers/ContainersDataGrid.tsx new file mode 100644 index 00000000..c5b7145b --- /dev/null +++ b/create-a-container/client/src/components/containers/ContainersDataGrid.tsx @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { DataVisNitroGrid, DataVisNitroSource } from '@mieweb/ui/datavis'; +import type { TableColumn, TableRendererProps } from '@mieweb/datavis'; +import { User } from 'lucide-react'; +import type { Container } from '@/lib/types'; +import { HttpLinks } from './HttpLinks'; +import { NodeLink } from './NodeLink'; +import { RowActions } from './RowActions'; +import { SshLinks } from './SshLinks'; +import { STATUS_LABELS, StatusBadge } from './StatusBadge'; +import { templateTitle } from './shared'; + +export interface ContainersDataGridProps { + containers: Container[]; + /** Whether to include the owner column (hidden when the list is scoped to self). */ + showOwner: boolean; + sessionUser?: string; + siteId?: string; + onDelete: (id: number) => void; + deleting: boolean; + canShare: (c: Container) => boolean; + onShare: (c: Container) => void; +} + +// Field types for the DataVis source payload. Custom-rendered columns +// (HTTP/SSH/actions) are omitted; their search text is supplied per-column. +const TYPE_INFO: { field: string; type: string }[] = [ + { field: 'hostname', type: 'string' }, + { field: 'status', type: 'string' }, + { field: 'nodeName', type: 'string' }, + { field: 'owner', type: 'string' }, + { field: 'template', type: 'string' }, +]; + +const asContainer = (row: Record) => row as unknown as Container; + +type FormatCell = NonNullable; + +/** + * Containers list rendered with DataVis NITRO. Rows are fed to the grid through + * an in-memory blob URL (the library's `http` source), and the custom cells + * (status badge, node/HTTP/SSH links, row actions) are rendered via `formatCell`. + */ +export function ContainersDataGrid({ + containers, + showOwner, + sessionUser, + siteId, + onDelete, + deleting, + canShare, + onShare, +}: ContainersDataGridProps) { + // DataVis's `http` source fetches a URL; a blob URL lets us feed local React + // Query data without a network round-trip. Rebuilt whenever the rows change. + const url = useMemo(() => { + const payload = { typeInfo: TYPE_INFO, data: containers }; + const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); + return URL.createObjectURL(blob); + }, [containers]); + useEffect(() => () => URL.revokeObjectURL(url), [url]); + + const columns = useMemo( + () => [ + { field: 'hostname', header: 'Hostname', sortable: true, filterable: true }, + { + field: 'status', + header: 'Status', + sortable: true, + filterable: true, + getSearchText: (_v, row) => { + const c = asContainer(row); + return STATUS_LABELS[c.status] ?? c.status; + }, + }, + { field: 'nodeName', header: 'Node', sortable: true, filterable: true }, + ...(showOwner + ? [{ field: 'owner', header: 'User', sortable: true, filterable: true } as TableColumn] + : []), + { + field: 'template', + header: 'Template', + sortable: true, + filterable: true, + getSearchText: (_v, row) => templateTitle(asContainer(row).template), + }, + { + field: 'httpEntries', + header: 'HTTP', + sortable: false, + filterable: false, + getSearchText: (_v, row) => + asContainer(row) + .httpEntries.map((h) => h.externalUrl ?? `:${h.port}`) + .join(' '), + }, + { + field: 'sshHost', + header: 'SSH', + sortable: false, + filterable: false, + getSearchText: (_v, row) => { + const c = asContainer(row); + return c.sshHost && c.sshPort ? `${c.sshHost}:${c.sshPort}` : ''; + }, + }, + { + field: 'actions', + header: '', + sortable: false, + filterable: false, + resizable: false, + reorderable: false, + align: 'right', + getSearchText: () => '', + }, + ], + [showOwner], + ); + + const formatCell = useCallback( + (value, row, column) => { + const c = asContainer(row); + switch (column.field) { + case 'hostname': + return {c.hostname}; + case 'status': + return ; + case 'nodeName': + return ; + case 'owner': + return ( + + + ); + case 'template': + return ( + + {templateTitle(c.template)} + + ); + case 'httpEntries': + return ; + case 'sshHost': + return ; + case 'actions': + return ( +
+ +
+ ); + default: + return value as React.ReactNode; + } + }, + [sessionUser, siteId, onDelete, deleting, canShare, onShare], + ); + + return ( + + + + ); +} diff --git a/create-a-container/client/src/components/containers/RowActions.tsx b/create-a-container/client/src/components/containers/RowActions.tsx index c26ced33..580e532b 100644 --- a/create-a-container/client/src/components/containers/RowActions.tsx +++ b/create-a-container/client/src/components/containers/RowActions.tsx @@ -34,6 +34,7 @@ export function RowActions({ aria-label={`Share ${c.hostname}`} leftIcon={} onClick={() => onShare(c)} + className="cursor-pointer" > Share @@ -58,7 +59,7 @@ export function RowActions({ }} disabled={deleting} // Danger tint on hover only; the built-in ghost transition still applies. - className="hover:bg-red-100 hover:text-red-700 dark:hover:bg-red-900/40 dark:hover:text-red-200" + className="cursor-pointer hover:bg-red-100 hover:text-red-700 dark:hover:bg-red-900/40 dark:hover:text-red-200" > Delete diff --git a/create-a-container/client/src/components/nodes/CredentialsBadge.tsx b/create-a-container/client/src/components/nodes/CredentialsBadge.tsx new file mode 100644 index 00000000..6965647d --- /dev/null +++ b/create-a-container/client/src/components/nodes/CredentialsBadge.tsx @@ -0,0 +1,23 @@ +import { Badge } from '@mieweb/ui'; +import type { Node } from '@/lib/types'; + +/** Whether the node has the credentials it needs to reach its hypervisor/host. */ +export function CredentialsBadge({ n }: { n: Node }) { + if (n.nodeType === 'dummy') { + return Not required; + } + + if (n.nodeType === 'docker') { + return n.apiUrl ? ( + Host set + ) : ( + Missing + ); + } + + return n.hasSecret ? ( + Set + ) : ( + Missing + ); +} diff --git a/create-a-container/client/src/components/nodes/NodeRowActions.tsx b/create-a-container/client/src/components/nodes/NodeRowActions.tsx new file mode 100644 index 00000000..18260242 --- /dev/null +++ b/create-a-container/client/src/components/nodes/NodeRowActions.tsx @@ -0,0 +1,46 @@ +import { Link } from 'react-router'; +import { Button } from '@mieweb/ui'; +import { Pencil, Trash2 } from 'lucide-react'; +import { ButtonLink } from '@/components/ButtonLink'; +import type { Node } from '@/lib/types'; + +/** Per-row actions for the nodes list: edit, delete. */ +export function NodeRowActions({ + n, + siteId, + onDelete, + deleting, +}: { + n: Node; + siteId?: string; + onDelete: (id: number) => void; + deleting: boolean; +}) { + return ( + <> + } + > + Edit + + + + ); +} diff --git a/create-a-container/client/src/components/nodes/NodeStats.tsx b/create-a-container/client/src/components/nodes/NodeStats.tsx new file mode 100644 index 00000000..1c1bfe75 --- /dev/null +++ b/create-a-container/client/src/components/nodes/NodeStats.tsx @@ -0,0 +1,97 @@ +import { useQuery } from '@tanstack/react-query'; +import { Alert, AlertDescription, Badge, Spinner } from '@mieweb/ui'; +import { ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import type { Node } from '@/lib/types'; +import { ResourceBar } from './ResourceBar'; + +/** Compact "3d 4h" style uptime. */ +function formatUptime(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +/** + * Expandable resource dashboard for a node. Fetched lazily (only mounts when the + * row is expanded) and polled while open so the bars track live utilization. + */ +export function NodeStats({ siteId, node }: { siteId?: string; node: Node }) { + const { data, isLoading, error } = useQuery({ + queryKey: keys.nodeStats(siteId!, node.id), + queryFn: () => queries.getNodeStats(siteId!, node.id), + enabled: !!siteId, + staleTime: 15_000, + refetchInterval: 30_000, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + {(error as ApiError).message} + + ); + } + + if (!data || !data.available) { + return ( + + + Live statistics are unavailable for this node + {node.nodeType === 'docker' ? ' (Docker host)' : ''}. + + + ); + } + + const cpuPct = data.cpu != null ? data.cpu * 100 : 0; + + return ( +
+
+ {data.status === 'online' ? 'Online' : (data.status ?? 'Online')} + {data.uptime != null && ( + Uptime: {formatUptime(data.uptime)} + )} +
+ +
+ + {data.memory && } + {data.swap && data.swap.total > 0 && ( + + )} + {data.rootfs && } +
+ + {data.storages.length > 0 && ( +
+ + Storage + +
+ {data.storages.map((s) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/create-a-container/client/src/components/nodes/NodesDataGrid.tsx b/create-a-container/client/src/components/nodes/NodesDataGrid.tsx new file mode 100644 index 00000000..ddbdad6c --- /dev/null +++ b/create-a-container/client/src/components/nodes/NodesDataGrid.tsx @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { DataVisNitroGrid, DataVisNitroSource } from '@mieweb/ui/datavis'; +import type { TableColumn, TableRendererProps } from '@mieweb/datavis'; +import type { Node } from '@/lib/types'; +import { CredentialsBadge } from './CredentialsBadge'; +import { NvidiaBadge } from './NvidiaBadge'; +import { NodeRowActions } from './NodeRowActions'; +import { NodeStats } from './NodeStats'; + +// Field types for the DataVis source payload; custom-rendered columns +// (credentials/actions) are omitted and supply their own search text. +const TYPE_INFO: { field: string; type: string }[] = [ + { field: 'name', type: 'string' }, + { field: 'ipv4Address', type: 'string' }, + { field: 'apiUrl', type: 'string' }, + { field: 'nvidiaAvailable', type: 'string' }, +]; + +const asNode = (row: Record) => row as unknown as Node; + +type FormatCell = NonNullable; +type DetailRow = NonNullable; + +export interface NodesDataGridProps { + nodes: Node[]; + siteId?: string; + onDelete: (id: number) => void; + deleting: boolean; +} + +/** + * Nodes list rendered with DataVis NITRO. Each row has a disclosure toggle that + * expands a full-width detail row (`NodeStats`) with live hardware-utilization + * bars. Rows are fed to the grid through an in-memory blob URL. + */ +export function NodesDataGrid({ nodes, siteId, onDelete, deleting }: NodesDataGridProps) { + const url = useMemo(() => { + const payload = { typeInfo: TYPE_INFO, data: nodes }; + const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); + return URL.createObjectURL(blob); + }, [nodes]); + useEffect(() => () => URL.revokeObjectURL(url), [url]); + + const columns = useMemo( + () => [ + { field: 'name', header: 'Name', sortable: true, filterable: true }, + { field: 'ipv4Address', header: 'IPv4', sortable: true, filterable: true }, + { field: 'apiUrl', header: 'API URL', sortable: true, filterable: true }, + { + field: 'nvidiaAvailable', + header: 'NVIDIA', + sortable: true, + filterable: true, + getSearchText: (_v, row) => (asNode(row).nvidiaAvailable ? 'Available' : 'No'), + }, + { + field: 'credentials', + header: 'Credentials', + sortable: false, + filterable: false, + getSearchText: () => '', + }, + { + field: 'actions', + header: '', + sortable: false, + filterable: false, + resizable: false, + reorderable: false, + align: 'right', + getSearchText: () => '', + }, + ], + [], + ); + + const formatCell = useCallback( + (value, row, column) => { + const n = asNode(row); + switch (column.field) { + case 'name': + return {n.name}; + case 'ipv4Address': + return {n.ipv4Address || '—'}; + case 'apiUrl': + return ( + + {n.apiUrl || '—'} + + ); + case 'nvidiaAvailable': + return ; + case 'credentials': + return ; + case 'actions': + return ( +
+ +
+ ); + default: + return value as React.ReactNode; + } + }, + [siteId, onDelete, deleting], + ); + + const renderDetailRow = useCallback( + (row) => , + [siteId], + ); + + return ( + + + + ); +} diff --git a/create-a-container/client/src/components/nodes/NvidiaBadge.tsx b/create-a-container/client/src/components/nodes/NvidiaBadge.tsx new file mode 100644 index 00000000..26d627f8 --- /dev/null +++ b/create-a-container/client/src/components/nodes/NvidiaBadge.tsx @@ -0,0 +1,11 @@ +import { Badge } from '@mieweb/ui'; +import type { Node } from '@/lib/types'; + +/** Whether the node advertises an NVIDIA GPU for container passthrough. */ +export function NvidiaBadge({ n }: { n: Node }) { + return n.nvidiaAvailable ? ( + Available + ) : ( + No + ); +} diff --git a/create-a-container/client/src/components/nodes/ResourceBar.tsx b/create-a-container/client/src/components/nodes/ResourceBar.tsx new file mode 100644 index 00000000..fa081612 --- /dev/null +++ b/create-a-container/client/src/components/nodes/ResourceBar.tsx @@ -0,0 +1,53 @@ +import { Progress } from '@mieweb/ui'; + +const KiB = 1024; +const MiB = KiB * 1024; +const GiB = MiB * 1024; +const TiB = GiB * 1024; + +/** Human-readable byte size using binary units labelled with familiar suffixes. */ +function formatBytes(bytes: number): string { + if (bytes >= TiB) return `${(bytes / TiB).toFixed(1)} TB`; + if (bytes >= GiB) return `${(bytes / GiB).toFixed(1)} GB`; + if (bytes >= MiB) return `${Math.round(bytes / MiB)} MB`; + if (bytes >= KiB) return `${Math.round(bytes / KiB)} KB`; + return `${bytes} B`; +} + +function variantFor(pct: number): 'success' | 'warning' | 'danger' { + if (pct >= 90) return 'danger'; + if (pct >= 75) return 'warning'; + return 'success'; +} + +export interface ResourceBarProps { + label: string; + /** Amount in use (bytes for `bytes`, percent 0..100 for `percent`). */ + used: number; + /** Capacity (bytes for `bytes`, 100 for `percent`). */ + total: number; + format?: 'bytes' | 'percent'; +} + +/** + * Labelled utilization bar (used / total) that colours by fill level: green + * under 75%, amber under 90%, red at or above 90%. + */ +export function ResourceBar({ label, used, total, format = 'bytes' }: ResourceBarProps) { + const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0; + const formatValue = (value: number, max: number) => + format === 'percent' + ? `${Math.round(max > 0 ? (value / max) * 100 : 0)}%` + : `${formatBytes(value)} / ${formatBytes(max)}`; + + return ( + 0 ? total : 1} + label={label} + showValue + formatValue={formatValue} + variant={variantFor(pct)} + /> + ); +} diff --git a/create-a-container/client/src/lib/queries.ts b/create-a-container/client/src/lib/queries.ts index 29b75e56..54ec9821 100644 --- a/create-a-container/client/src/lib/queries.ts +++ b/create-a-container/client/src/lib/queries.ts @@ -15,6 +15,7 @@ import type { Job, JobStatusRow, Node, + NodeStats, AppSettings, ResourceRequest, Site, @@ -27,6 +28,8 @@ export const keys = { nodes: (siteId: number | string) => ['sites', String(siteId), 'nodes'] as const, node: (siteId: number | string, id: number | string) => ['sites', String(siteId), 'nodes', String(id)] as const, + nodeStats: (siteId: number | string, id: number | string) => + ['sites', String(siteId), 'nodes', String(id), 'stats'] as const, containers: (siteId: number | string, params?: Record) => ['sites', String(siteId), 'containers', params ?? {}] as const, container: (siteId: number | string, id: number | string) => @@ -61,6 +64,8 @@ export const queries = { api.get(`/api/v1/sites/${siteId}/nodes`), getNode: (siteId: number | string, id: number | string) => api.get(`/api/v1/sites/${siteId}/nodes/${id}`), + getNodeStats: (siteId: number | string, id: number | string) => + api.get(`/api/v1/sites/${siteId}/nodes/${id}/stats`), // Agents listAgents: () => api.get('/api/v1/agents'), diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index fbb7d30d..d24f3b0b 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -31,6 +31,41 @@ export interface Node { hasSecret: boolean; } +/** Used/total byte pair for a node hardware resource. */ +export interface NodeResourceUsage { + used: number; + total: number; +} + +/** Per-datastore usage reported by the hypervisor. */ +export interface NodeStorageUsage { + name: string; + type: string | null; + content: string | null; + used: number; + total: number; + avail: number; +} + +/** + * Live hardware utilization for a node. `available` is false when the node has + * no API credentials or the hypervisor could not be reached; the remaining + * fields keep their shape so the UI can render an "unavailable" state. + */ +export interface NodeStats { + available: boolean; + status: string | null; + /** CPU load as a 0..1 fraction of total capacity. */ + cpu: number | null; + cpuCount: number | null; + memory: NodeResourceUsage | null; + swap: NodeResourceUsage | null; + rootfs: NodeResourceUsage | null; + /** Uptime in seconds. */ + uptime: number | null; + storages: NodeStorageUsage[]; +} + export interface AgentServiceStatus { /** systemd ActiveState: active, inactive, failed, ... */ state: string; diff --git a/create-a-container/client/src/pages/containers/ContainersListPage.tsx b/create-a-container/client/src/pages/containers/ContainersListPage.tsx index 233eddc5..7f43bb9e 100644 --- a/create-a-container/client/src/pages/containers/ContainersListPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainersListPage.tsx @@ -5,9 +5,6 @@ import { Alert, AlertDescription, AlertTitle, - Button, - Card, - CardTitle, Modal, ModalBody, ModalClose, @@ -15,40 +12,20 @@ import { ModalTitle, PageHeader, Spinner, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, useToast, } from '@mieweb/ui'; -import { - Container as ContainerIcon, - LayoutGrid, - Plus, - Rows3, - Server, - User, -} from 'lucide-react'; +import { Container as ContainerIcon, Plus, Server } from 'lucide-react'; import { api, ApiError } from '@/lib/api'; import { useSession } from '@/lib/auth'; import { keys, queries } from '@/lib/queries'; import { ButtonLink } from '@/components/ButtonLink'; -import { HttpLinks } from '@/components/containers/HttpLinks'; -import { Meta } from '@/components/containers/Meta'; -import { NodeLink } from '@/components/containers/NodeLink'; -import { RowActions } from '@/components/containers/RowActions'; -import { STATUS_LABELS, StatusBadge } from '@/components/containers/StatusBadge'; -import { SshLinks } from '@/components/containers/SshLinks'; +import { STATUS_LABELS } from '@/components/containers/StatusBadge'; import { templateTitle } from '@/components/containers/shared'; import { CollaboratorsManager } from '@/components/containers/CollaboratorsManager'; +import { ContainersDataGrid } from '@/components/containers/ContainersDataGrid'; import { ContainerFilters, type FilterOption } from '@/components/containers/ContainerFilters'; import type { Container, ContainerStatus } from '@/lib/types'; -type ViewMode = 'cards' | 'table'; -const VIEW_STORAGE_KEY = 'containers:view'; - export function ContainersListPage() { const { siteId } = useParams<{ siteId: string }>(); const qc = useQueryClient(); @@ -69,9 +46,9 @@ export function ContainersListPage() { const selectedStatuses = parseList(searchParams.get('status')); const selectedTemplates = parseList(searchParams.get('template')); const hostnameQuery = searchParams.get('q') ?? ''; - // A row may belong to another owner unless the filter is narrowed to just - // the caller, so the owner column only disappears then. - const showOwner = selectedUsers.length === 0 || selectedUsers.some((u) => u !== sessionUser); + // The default (and a self-only filter) shows only the caller's containers, so + // the owner column only appears once another user is included in the filter. + const showOwner = selectedUsers.some((u) => u !== sessionUser); const dnsWarnings = (location.state as { dnsWarnings?: string[] } | null)?.dnsWarnings; const setListParam = (key: string, values: string[]) => @@ -94,8 +71,8 @@ export function ContainersListPage() { }, { replace: true }, ); - // The unfiltered default already shows everything the caller may see; - // select-all in the dropdown is just an explicit form of the same thing. + // Clearing all filters returns to the default view: the caller's own + // containers. const clearAllFilters = () => setSearchParams( (prev) => { @@ -106,28 +83,16 @@ export function ContainersListPage() { { replace: true }, ); - const [view, setView] = useState(() => { - const stored = typeof window !== 'undefined' ? window.localStorage.getItem(VIEW_STORAGE_KEY) : null; - return stored === 'table' ? 'table' : 'cards'; - }); // Container whose sharing dialog is open. Tracked by id so the dialog reflects // live collaborator changes after the list query refetches. const [shareTargetId, setShareTargetId] = useState(null); - const changeView = (next: ViewMode) => { - setView(next); - try { - window.localStorage.setItem(VIEW_STORAGE_KEY, next); - } catch { - /* ignore storage failures */ - } - }; const { data: site } = useQuery({ queryKey: keys.site(siteId!), queryFn: () => queries.getSite(siteId!), enabled: !!siteId, }); - // Empty selection returns everything the caller may see server-side. + // Empty selection defaults to the caller's own containers server-side. const userParam = selectedUsers.length > 0 ? selectedUsers : undefined; const { data, isLoading, error } = useQuery({ queryKey: keys.containers(siteId!, { user: userParam, nodeId }), @@ -137,17 +102,16 @@ export function ContainersListPage() { // Options for the "User" filter. Admins may filter by any user; non-admins // may only narrow to owners who have shared a container with them, so their - // option list is derived from the unfiltered list (own + shared). With no - // owner filter selected this is the same query as the list above, so React - // Query dedupes it into a single fetch. + // option list is derived from a `*` (own + shared) query — distinct from the + // own-only default list, so both run. const { data: allUsers } = useQuery({ queryKey: keys.users(), queryFn: queries.listUsers, enabled: !!siteId && isAdmin, }); const { data: visibleContainers } = useQuery({ - queryKey: keys.containers(siteId!, {}), - queryFn: () => queries.listContainers(siteId!), + queryKey: keys.containers(siteId!, { user: ['*'] }), + queryFn: () => queries.listContainers(siteId!, { user: ['*'] }), enabled: !!siteId && !isAdmin, }); @@ -227,34 +191,6 @@ export function ContainersListPage() { icon={} actions={
- {serverHasContainers && ( -
- - -
- )} }> Nodes @@ -319,116 +255,17 @@ export function ContainersListPage() { )} - {hasContainers && view === 'cards' && ( -
- {visible.map((c: Container) => ( - -
- - {c.hostname} - - -
-
- setShareTargetId(target.id)} - /> -
-
- - - - {showOwner && ( - - - - - )} - - - {templateTitle(c.template)} - - - - - - - - -
-
- ))} -
- )} - - {hasContainers && view === 'table' && ( - - - - Hostname - Status - Node - {showOwner && User} - Template - HTTP - SSH - Actions - - - - {visible.map((c: Container) => ( - - {c.hostname} - - - - - - - {showOwner && ( - - - - - )} - - {templateTitle(c.template)} - - - - - - - - - setShareTargetId(target.id)} - /> - - - ))} - -
+ {hasContainers && ( + setShareTargetId(target.id)} + /> )} - - {label} - - {children} - - ); -} - -function NvidiaBadge({ n }: { n: Node }) { - return n.nvidiaAvailable ? ( - Available - ) : ( - No - ); -} - -function CredentialsBadge({ n }: { n: Node }) { - if (n.nodeType === 'dummy') { - return Not required; - } - - if (n.nodeType === 'docker') { - return n.apiUrl ? Host set : Missing; - } - - return n.hasSecret ? Set : Missing; -} - -function RowActions({ - n, - siteId, - onDelete, - deleting, -}: { - n: Node; - siteId?: string; - onDelete: (id: number) => void; - deleting: boolean; -}) { - return ( - <> - } - > - Edit - - - - ); -} export function NodesListPage() { const { siteId } = useParams<{ siteId: string }>(); @@ -108,19 +18,6 @@ export function NodesListPage() { enabled: !!siteId, }); - const [view, setView] = useState(() => { - const stored = typeof window !== 'undefined' ? window.localStorage.getItem(VIEW_STORAGE_KEY) : null; - return stored === 'table' ? 'table' : 'cards'; - }); - const changeView = (next: ViewMode) => { - setView(next); - try { - window.localStorage.setItem(VIEW_STORAGE_KEY, next); - } catch { - /* ignore storage failures */ - } - }; - const del = useMutation({ mutationFn: (id: number) => api.delete(`/api/v1/sites/${siteId}/nodes/${id}`), onSuccess: () => { @@ -140,34 +37,6 @@ export function NodesListPage() { icon={} actions={
- {hasNodes && ( -
- - -
- )} )} - {hasNodes && view === 'cards' && ( -
- {data.map((n: Node) => ( - -
- - {n.name} - - -
-
- -
-
- - {n.ipv4Address || '—'} - - - - {n.apiUrl || '—'} - - - - - -
-
- ))} -
- )} - - {hasNodes && view === 'table' && ( - - - - Name - IPv4 - API URL - NVIDIA - Credentials - Actions - - - - {data.map((n: Node) => ( - - {n.name} - {n.ipv4Address || '—'} - {n.apiUrl || '—'} - - - - - - - - - - - ))} - -
+ {hasNodes && ( + )}
); diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index 87239337..8e9d63b8 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -1033,6 +1033,7 @@ paths: parameters: - { in: path, name: siteId, required: true, schema: { type: integer } } - { in: path, name: id, required: true, schema: { type: integer } } +<<<<<<< Updated upstream responses: '200': description: Array of Proxmox CT template storages @@ -1040,6 +1041,21 @@ paths: application/json: schema: { type: object, properties: { data: { type: array, items: { type: object, properties: { name: { type: string }, total: { type: integer }, available: { type: integer } } } } } } '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } +======= + responses: { '200': { description: Array of Proxmox CT template storages } } + /sites/{siteId}/nodes/{id}/stats: + get: + tags: [Nodes] + parameters: + - { in: path, name: siteId, required: true, schema: { type: integer } } + - { in: path, name: id, required: true, schema: { type: integer } } + responses: + '200': + description: >- + Live hardware utilization (CPU/memory/swap/rootfs, uptime) plus + per-datastore usage. `available` is false when the node has no API + credentials or the hypervisor is unreachable. +>>>>>>> Stashed changes /jobs/{id}: parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 95f683a2..37451fc6 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -201,12 +201,13 @@ const CONTAINER_INCLUDE = [ * `query.user` must be an array (callers capture `req.query` once and default * it with `query.user ??= []` — Express 5 re-parses req.query on each access; * clients send bracket notation, e.g. `user[0]=alice`, which the 'extended' - * query parser coerces to an array). Empty -> everything the caller - * may see: every container on the site for admins, their own plus any shared - * with them for non-admins. A list of names -> those owners for admins; for - * non-admins the same list intersected with what they may already see (own - * plus shared), so a non-admin can only narrow down to owners who have shared - * a container with them and never widen their visibility. + * query parser coerces to an array). Empty -> the caller's own containers (the + * default view). `*` -> everything the caller may see: every container on the + * site for admins, their own plus any shared with them for non-admins. A list + * of names -> those owners for admins; for non-admins the same list + * intersected with what they may see (own plus shared), so a non-admin can + * surface a sharer's containers by filtering on that username but can never + * widen their visibility. * * @param {object} query - req.query * @param {number[]} nodeIds - IDs of the nodes belonging to the site @@ -224,19 +225,34 @@ function buildContainerListWhere(query, nodeIds, session) { } const names = query.user; + // An explicit `*` means "everything I'm allowed to see": all owners for + // admins, own + shared for non-admins. Absent/empty defaults to just the + // caller's own containers — shared containers surface when you filter by the + // sharer's username (see the share dialog copy). + const wantsAll = names.includes('*'); + if (session.isAdmin) { - // Admins may see every owner on the site; a name list simply narrows it. - if (names.length > 0) where.username = names; + if (wantsAll) return where; // every owner on the site + if (names.length === 0) { + where.username = session.user; // default: own only + return where; + } + where.username = names; // specific owners return where; } - // Non-admins may only see their own containers plus any shared with them. + // Non-admins may never see beyond their own containers plus shared ones. const visible = visibleToClauses(session.user); + if (wantsAll) { + where[Sequelize.Op.or] = visible; // own + shared + return where; + } if (names.length === 0) { - where[Sequelize.Op.or] = visible; - } else { - // Intersect the requested owners with what the caller may already see. - where[Sequelize.Op.and] = [{ [Sequelize.Op.or]: visible }, { username: names }]; + where.username = session.user; // default: own only + return where; } + // Intersect the requested owners with what the caller may see, so filtering + // by a sharer's username surfaces the containers they shared. + where[Sequelize.Op.and] = [{ [Sequelize.Op.or]: visible }, { username: names }]; return where; } @@ -359,12 +375,13 @@ router.get( const site = await loadSite(req.params.siteId); const nodes = await Node.findAll({ where: { siteId: site.id }, attributes: ['id'] }); const nodeIds = nodes.map((n) => n.id); - // `user` filter backs the list page's User filter: omitted, it returns - // everything the caller may see (all owners for admins; own + shared for - // non-admins); `user[0]=` narrows to specific owners. See - // buildContainerListWhere. Express 5's req.query is a getter that re-parses - // on every access, so capture it once — defaulting `req.query.user` directly - // would mutate a throwaway object. + // `user` filter backs the list page's User filter: omitted, it returns the + // caller's own containers (the default); `user[0]=*` returns everything the + // caller may see (all owners for admins; own + shared for non-admins); + // `user[0]=` selects specific owners. See buildContainerListWhere. + // Express 5's req.query is a getter that re-parses on every access, so + // capture it once — defaulting `req.query.user` directly would mutate a + // throwaway object. const query = req.query; query.user ??= []; const where = buildContainerListWhere(query, nodeIds, req.session); diff --git a/create-a-container/routers/api/v1/nodes.js b/create-a-container/routers/api/v1/nodes.js index 888e8ef0..b50ad716 100644 --- a/create-a-container/routers/api/v1/nodes.js +++ b/create-a-container/routers/api/v1/nodes.js @@ -99,6 +99,76 @@ router.get( }), ); +// GET /nodes/:id/stats — live hardware utilization (CPU/memory/swap/rootfs) plus +// per-datastore usage, used by the expandable resource dashboard in the UI. +router.get( + '/:id/stats', + asyncHandler(async (req, res) => { + const site = await loadSite(req); + const node = await Node.findOne({ + where: { id: parseInt(req.params.id, 10), siteId: site.id }, + }); + if (!node) throw new ApiError(404, 'not_found', 'Node not found'); + + // Shape stays stable whether or not live data is reachable, so the client + // can render "unavailable" without special-casing missing fields. + const empty = { + available: false, + status: null, + cpu: null, + cpuCount: null, + memory: null, + swap: null, + rootfs: null, + uptime: null, + storages: [], + }; + if (!node.hasApiAccess()) return ok(res, empty); + + const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); + const usage = (o) => (o ? { used: num(o.used), total: num(o.total) } : null); + + try { + const client = await node.api(); + if (typeof client.nodeStatus !== 'function') return ok(res, empty); + const status = await client.nodeStatus(node.name); + + let storages = []; + try { + const list = await client.datastores(node.name, null, true); + storages = (list || []) + .filter((s) => num(s.total) > 0) + .map((s) => ({ + name: s.storage, + type: s.type || null, + content: s.content || null, + used: num(s.used), + total: num(s.total), + avail: num(s.avail), + })); + } catch (err) { + console.error(`Error fetching storages for node ${node.name}:`, err.message); + } + + return ok(res, { + available: true, + // /status has no run-state field; reaching it means the node is up. + status: 'online', + cpu: typeof status.cpu === 'number' ? status.cpu : null, + cpuCount: status.cpuinfo?.cpus ?? null, + memory: usage(status.memory), + swap: usage(status.swap), + rootfs: usage(status.rootfs), + uptime: status.uptime ?? null, + storages, + }); + } catch (err) { + console.error(`Error fetching stats for node ${node.name}:`, err.message); + return ok(res, empty); + } + }), +); + router.post( '/', apiAdmin, diff --git a/create-a-container/utils/dummy-api.js b/create-a-container/utils/dummy-api.js index 8b065ab5..0dc0de0c 100644 --- a/create-a-container/utils/dummy-api.js +++ b/create-a-container/utils/dummy-api.js @@ -88,6 +88,31 @@ class DummyApi { return vmid; } + // --- Node status ----------------------------------------------------------- + + /** + * Simulated live node status so the resource dashboards render locally + * without a hypervisor. Values jitter slightly per call to look "live". + */ + async nodeStatus() { + const GiB = 1024 * 1024 * 1024; + const memTotal = 32 * GiB; + const memUsed = Math.round(memTotal * (0.35 + Math.random() * 0.2)); + const swapTotal = 8 * GiB; + const swapUsed = Math.round(swapTotal * (0.05 + Math.random() * 0.1)); + const diskTotal = 500 * GiB; + const diskUsed = Math.round(diskTotal * (0.4 + Math.random() * 0.15)); + return { + cpu: 0.1 + Math.random() * 0.3, + cpuinfo: { cpus: 8 }, + memory: { total: memTotal, used: memUsed, free: memTotal - memUsed }, + swap: { total: swapTotal, used: swapUsed, free: swapTotal - swapUsed }, + rootfs: { total: diskTotal, used: diskUsed, avail: diskTotal - diskUsed, free: diskTotal - diskUsed }, + uptime: 3600 * 72, + loadavg: ['0.20', '0.15', '0.10'], + }; + } + // --- Storage --------------------------------------------------------------- /** diff --git a/create-a-container/utils/proxmox-api.js b/create-a-container/utils/proxmox-api.js index 8a287a13..e24fd86f 100644 --- a/create-a-container/utils/proxmox-api.js +++ b/create-a-container/utils/proxmox-api.js @@ -127,6 +127,16 @@ class ProxmoxApi { return response.data.data; } + /** + * Get live status for a node (CPU load, memory, swap, rootfs, uptime). + * @param {string} node - The node name + * @returns {Promise} - The API response data (cpu, cpuinfo, memory, swap, rootfs, uptime, ...) + */ + async nodeStatus(node) { + const response = await axios.get(`${this.baseUrl}/api2/json/nodes/${node}/status`, this.options); + return response.data.data; + } + /** * Get cluster resources * @param {'node'|'storage'|'pool'|'qemu'|'lxc'|'openvz'|'sdn'|'network'|'vm'} type From d8e5b148e86ddb82a18541a1301b3acd756a8833 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Wed, 5 Aug 2026 08:34:43 -0700 Subject: [PATCH 2/5] Potential fix for pull request finding 'CodeQL / Type confusion through parameter tampering' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- create-a-container/routers/api/v1/containers.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 37451fc6..8d1d676b 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -224,7 +224,9 @@ function buildContainerListWhere(query, nodeIds, session) { where.nodeId = Number.isInteger(nodeId) && nodeIds.includes(nodeId) ? nodeId : -1; } - const names = query.user; + const names = Array.isArray(query.user) + ? query.user + : (typeof query.user === 'string' ? [query.user] : []); // An explicit `*` means "everything I'm allowed to see": all owners for // admins, own + shared for non-admins. Absent/empty defaults to just the // caller's own containers — shared containers surface when you filter by the From 30b1656c180920c76825d3462020552269d3d580 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Wed, 5 Aug 2026 08:35:06 -0700 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- create-a-container/client/package-lock.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/create-a-container/client/package-lock.json b/create-a-container/client/package-lock.json index 359ad13d..88dcc594 100644 --- a/create-a-container/client/package-lock.json +++ b/create-a-container/client/package-lock.json @@ -9,12 +9,8 @@ "version": "0.0.0", "dependencies": { "@hookform/resolvers": "^3.9.1", -<<<<<<< Updated upstream - "@mieweb/ui": "*", -======= "@mieweb/datavis": "1.6.0", "@mieweb/ui": "^0.7.1", ->>>>>>> Stashed changes "@tanstack/react-query": "^5.62.0", "datavis-ace": "4.1.0", "lucide-react": "^0.460.0", From 4374a697fbdd4c0ec0a4f4012695222f6ee6b69d Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Wed, 5 Aug 2026 10:22:17 -0700 Subject: [PATCH 4/5] Simplify container filtering UX Replace the old container filter bar with built-in grid filters and a persisted default owner view. Move the container view preference into Settings, make the Settings link available to all users, but gate admin settings, and clean up the related OpenAPI merge. --- create-a-container/client/src/app/Header.tsx | 14 +- create-a-container/client/src/app/Sidebar.tsx | 2 +- .../containers/ContainerFilters.tsx | 110 ------------ .../containers/ContainersDataGrid.tsx | 32 +++- .../containers/DefaultOwnerFilter.tsx | 30 ++++ .../src/components/containers/MultiSelect.tsx | 69 -------- .../settings/ContainerViewPreference.tsx | 37 ++++ .../client/src/lib/containerViewPreference.ts | 53 ++++++ .../pages/containers/ContainersListPage.tsx | 162 ++---------------- .../src/pages/settings/SettingsPage.tsx | 31 +++- create-a-container/openapi.v1.yaml | 15 +- 11 files changed, 198 insertions(+), 357 deletions(-) delete mode 100644 create-a-container/client/src/components/containers/ContainerFilters.tsx create mode 100644 create-a-container/client/src/components/containers/DefaultOwnerFilter.tsx delete mode 100644 create-a-container/client/src/components/containers/MultiSelect.tsx create mode 100644 create-a-container/client/src/components/settings/ContainerViewPreference.tsx create mode 100644 create-a-container/client/src/lib/containerViewPreference.ts diff --git a/create-a-container/client/src/app/Header.tsx b/create-a-container/client/src/app/Header.tsx index 2f5f81d8..52b70623 100644 --- a/create-a-container/client/src/app/Header.tsx +++ b/create-a-container/client/src/app/Header.tsx @@ -78,14 +78,12 @@ export function AppTopHeader() { /> - {isAdmin && ( - - )} +