diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 68364fe49..f59d5f75b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node { - "name": "my-nethesis-ui", + "name": "my-nethesis", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "build": { "context": "..", @@ -10,7 +10,7 @@ }, "workspaceMount": "source=${localWorkspaceFolder},target=/app,type=bind,Z", "workspaceFolder": "/app", - "runArgs": ["--userns=keep-id", "--name=my-nethesis-ui-dev"], + "runArgs": ["--userns=keep-id", "--name=my-nethesis-dev"], "appPort": "5173:5173", "customizations": { "vscode": { diff --git a/backend/.render-build-trigger b/backend/.render-build-trigger index 3bb7a5909..89e6edccd 100644 --- a/backend/.render-build-trigger +++ b/backend/.render-build-trigger @@ -2,9 +2,9 @@ # This file is used to force Docker service rebuilds in PR previews # Modify LAST_UPDATE to trigger rebuilds -LAST_UPDATE=2026-02-10T12:10:22Z +LAST_UPDATE=2026-03-09T09:01:19Z # Instructions: # 1. To force rebuild of Docker services in a PR, update LAST_UPDATE -# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-02-10T12:10:22Z +# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-03-09T09:01:19Z # 2. Commit and push changes to trigger Docker rebuilds \ No newline at end of file diff --git a/collect/.render-build-trigger b/collect/.render-build-trigger index 3bb7a5909..89e6edccd 100644 --- a/collect/.render-build-trigger +++ b/collect/.render-build-trigger @@ -2,9 +2,9 @@ # This file is used to force Docker service rebuilds in PR previews # Modify LAST_UPDATE to trigger rebuilds -LAST_UPDATE=2026-02-10T12:10:22Z +LAST_UPDATE=2026-03-09T09:01:19Z # Instructions: # 1. To force rebuild of Docker services in a PR, update LAST_UPDATE -# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-02-10T12:10:22Z +# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-03-09T09:01:19Z # 2. Commit and push changes to trigger Docker rebuilds \ No newline at end of file diff --git a/frontend/.render-build-trigger b/frontend/.render-build-trigger index 3bb7a5909..89e6edccd 100644 --- a/frontend/.render-build-trigger +++ b/frontend/.render-build-trigger @@ -2,9 +2,9 @@ # This file is used to force Docker service rebuilds in PR previews # Modify LAST_UPDATE to trigger rebuilds -LAST_UPDATE=2026-02-10T12:10:22Z +LAST_UPDATE=2026-03-09T09:01:19Z # Instructions: # 1. To force rebuild of Docker services in a PR, update LAST_UPDATE -# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-02-10T12:10:22Z +# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-03-09T09:01:19Z # 2. Commit and push changes to trigger Docker rebuilds \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d82845af..bf313d07b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@nethesis/vue-components": "^3.6.0", "@pinia/colada": "^0.21.5", "@tailwindcss/vite": "^4.1.10", + "@vuepic/vue-datepicker": "^12.1.0", "@vueuse/core": "^13.4.0", "axios": "^1.11.0", "lodash": "^4.17.21", @@ -39,6 +40,7 @@ "@vitest/coverage-v8": "^3.2.4", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.5.0", + "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.7.0", "eslint": "^9.22.0", "eslint-plugin-vue": "~10.0.0", @@ -728,6 +730,12 @@ "node": ">=18" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", @@ -1320,6 +1328,68 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@floating-ui/vue": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz", + "integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6", + "@floating-ui/utils": "^0.2.11", + "vue-demi": ">=0.13.0" + } + }, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@fontsource/poppins": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.2.7.tgz", @@ -1637,7 +1707,7 @@ }, "node_modules/@nethesis/nethesis-light-svg-icons": { "version": "6.2.1", - "resolved": "git+ssh://git@github.com/nethesis/Font-Awesome.git#68498993562d2c62b5361b1b9852ae48ed08f16c", + "resolved": "git+ssh://git@github.com/nethesis/Font-Awesome.git#675844c1b083aa7308a0cd2b0f430a6dfd442d1a", "hasInstallScript": true, "license": "UNLICENSED", "dependencies": { @@ -1812,6 +1882,13 @@ "node": ">= 8" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, "node_modules/@pinia/colada": { "version": "0.21.7", "resolved": "https://registry.npmjs.org/@pinia/colada/-/colada-0.21.7.tgz", @@ -3388,6 +3465,17 @@ "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, "node_modules/@vue/tsconfig": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.7.0.tgz", @@ -3407,6 +3495,72 @@ } } }, + "node_modules/@vuepic/vue-datepicker": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-12.1.0.tgz", + "integrity": "sha512-QuWcO+CqIGYFoRNCagp9xUY9sMK/OHUlVIDxBYjw7HjCTWXfuE/r3l3loB00faEtb0Teo3DeBn26hT3tYA5pgg==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "@floating-ui/vue": "^1.1.9", + "@vueuse/core": "^14.1.0", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "vue": ">=3.5.0" + } + }, + "node_modules/@vuepic/vue-datepicker/node_modules/@vueuse/core": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vuepic/vue-datepicker/node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vuepic/vue-datepicker/node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vuepic/vue-datepicker/node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/@vueuse/core": { "version": "13.9.0", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz", @@ -3445,6 +3599,16 @@ "vue": "^3.5.0" } }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3869,6 +4033,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3876,6 +4050,17 @@ "dev": true, "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4116,6 +4301,25 @@ "dev": true, "license": "MIT" }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.218", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz", @@ -5147,6 +5351,13 @@ "node": ">=0.8.19" } }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -5400,6 +5611,38 @@ "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", "license": "BSD-3-Clause" }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6083,6 +6326,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/npm-normalize-package-bin": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", @@ -6661,6 +6920,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -7948,6 +8214,13 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-eslint-parser": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0e92cf39e..945b7d5ab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@nethesis/vue-components": "^3.6.0", "@pinia/colada": "^0.21.5", "@tailwindcss/vite": "^4.1.10", + "@vuepic/vue-datepicker": "^12.1.0", "@vueuse/core": "^13.4.0", "axios": "^1.11.0", "lodash": "^4.17.21", @@ -49,6 +50,7 @@ "@vitest/coverage-v8": "^3.2.4", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.5.0", + "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.7.0", "eslint": "^9.22.0", "eslint-plugin-vue": "~10.0.0", diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 069095cff..9a54d742e 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -8,6 +8,9 @@ @import 'tailwindcss'; +@import '@vuepic/vue-datepicker/dist/main.css' layer(vendor); +@import './vue-datepicker.css'; + /* Poppins font */ @import '@fontsource/poppins'; diff --git a/frontend/src/assets/system_logos/nethserver.svg b/frontend/src/assets/system_logos/nethserver.svg index 20a94141d..ef3cc503b 100644 --- a/frontend/src/assets/system_logos/nethserver.svg +++ b/frontend/src/assets/system_logos/nethserver.svg @@ -1,6 +1,7 @@ - - - - + + + + + diff --git a/frontend/src/assets/vue-datepicker.css b/frontend/src/assets/vue-datepicker.css new file mode 100644 index 000000000..0790eb640 --- /dev/null +++ b/frontend/src/assets/vue-datepicker.css @@ -0,0 +1,37 @@ +/* + Copyright (C) 2026 Nethesis S.r.l. + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +/* VueDatePicker customizations */ +.vue-datepicker { + @apply w-auto!; + --dp-font-family: 'Poppins', sans-serif; + --dp-font-size: var(--text-sm); + --dp-border-radius: var(--radius-md); +} + +.dp__theme_light { + --dp-background-color: var(--color-white); + --dp-primary-color: var(--color-primary-700); + --dp-menu-border-color: var(--color-gray-200); + --dp-hover-color: var(--color-gray-200); +} + +.dp__theme_dark { + --dp-background-color: var(--color-gray-950); + --dp-primary-color: var(--color-primary-500); + --dp-primary-text-color: var(--color-gray-950); + --dp-menu-border-color: var(--color-gray-700); + --dp-hover-color: var(--color-gray-800); +} + +.dp__menu { + box-shadow: var(--shadow-lg); +} + +/* Hide the menu arrow */ +.dp__arrow_top, +.dp__arrow_bottom { + display: none; +} diff --git a/frontend/src/components/CounterCard.vue b/frontend/src/components/CounterCard.vue index a5cf33be2..56307c334 100644 --- a/frontend/src/components/CounterCard.vue +++ b/frontend/src/components/CounterCard.vue @@ -17,6 +17,7 @@ const { skeletonLines = 2, uppercaseTitle = true, centeredCounter = true, + colorClasses = undefined, } = defineProps<{ title: string counter: number @@ -25,6 +26,7 @@ const { skeletonLines?: number uppercaseTitle?: boolean centeredCounter?: boolean + colorClasses?: string }>() const slots = useSlots() @@ -49,7 +51,8 @@ const hasDefaultSlot = computed(() => !!slots.default) diff --git a/frontend/src/components/account/ProfilePanel.vue b/frontend/src/components/account/ProfilePanel.vue index 701101d73..2a58362f0 100644 --- a/frontend/src/components/account/ProfilePanel.vue +++ b/frontend/src/components/account/ProfilePanel.vue @@ -153,6 +153,8 @@ function validate(profile: ProfileInfo): boolean { :label="$t('users.phone_number')" :invalid-message="validationIssues.phone?.[0] ? $t(validationIssues.phone[0]) : ''" :disabled="editUserLoading || loginStore.isOwner || loginStore.isImpersonating" + :optional="true" + :optional-label="t('common.optional')" />
diff --git a/frontend/src/components/applications/ApplicationInfoCard.vue b/frontend/src/components/applications/ApplicationInfoCard.vue index 8009bf7b7..abcf513f6 100644 --- a/frontend/src/components/applications/ApplicationInfoCard.vue +++ b/frontend/src/components/applications/ApplicationInfoCard.vue @@ -20,7 +20,8 @@ import DataItem from '@/components/DataItem.vue' import NotesModal from '@/components/NotesModal.vue' import OrganizationIcon from '@/components/organizations/OrganizationIcon.vue' import OrganizationLink from '@/components/applications/OrganizationLink.vue' -import { getApplicationLogo, getDisplayName } from '@/lib/applications/applications' +import { getDisplayName } from '@/lib/applications/applications' +import ApplicationLogo from '@/components/applications/ApplicationLogo.vue' import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { canManageApplications } from '@/lib/permissions' @@ -100,13 +101,7 @@ function getKebabMenuItems() {
- + {{ getDisplayName(applicationDetail.data) }} diff --git a/frontend/src/components/applications/ApplicationLogo.vue b/frontend/src/components/applications/ApplicationLogo.vue new file mode 100644 index 000000000..3308b79c7 --- /dev/null +++ b/frontend/src/components/applications/ApplicationLogo.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/frontend/src/components/applications/ApplicationsTable.vue b/frontend/src/components/applications/ApplicationsTable.vue index 1c11dcb5c..5475abba4 100644 --- a/frontend/src/components/applications/ApplicationsTable.vue +++ b/frontend/src/components/applications/ApplicationsTable.vue @@ -24,7 +24,6 @@ import { NeEmptyState, NeInlineNotification, NeTextInput, - NeSpinner, NeDropdown, type SortEvent, NeTooltip, @@ -39,11 +38,8 @@ import { canManageApplications } from '@/lib/permissions' import { SYSTEMS_TABLE_ID } from '@/lib/systems/systems' import OrganizationIcon from '../organizations/OrganizationIcon.vue' import { useApplications } from '@/queries/applications/applications' -import { - getApplicationLogo, - getDisplayName, - type Application, -} from '@/lib/applications/applications' +import { getDisplayName, type Application } from '@/lib/applications/applications' +import ApplicationLogo from './ApplicationLogo.vue' import { faGridOne } from '@nethesis/nethesis-solid-svg-icons' import AssignOrganizationDrawer from './AssignOrganizationDrawer.vue' import SetNotesDrawer from './SetNotesDrawer.vue' @@ -51,6 +47,7 @@ import OrganizationLink from './OrganizationLink.vue' import { useApplicationFilters } from '@/queries/applications/applicationFilters' import { buildVersionFilterOptions } from '@/lib/applications/applicationFilters' import router from '@/router' +import UpdatingSpinner from '@/components/UpdatingSpinner.vue' const { t } = useI18n() const { @@ -312,15 +309,7 @@ const goToApplicationDetails = (application: Application) => {
-
- -
- {{ $t('common.updating') }} -
-
+
@@ -377,13 +366,7 @@ const goToApplicationDetails = (application: Application) => {
- + {{ item.name || '-' }} diff --git a/frontend/src/components/customers/CustomersTable.vue b/frontend/src/components/customers/CustomersTable.vue index db72e5191..b4b961dfb 100644 --- a/frontend/src/components/customers/CustomersTable.vue +++ b/frontend/src/components/customers/CustomersTable.vue @@ -31,7 +31,6 @@ import { NeEmptyState, NeInlineNotification, NeTextInput, - NeSpinner, NeDropdown, type SortEvent, NeSortDropdown, @@ -51,6 +50,7 @@ import { savePageSizeToStorage } from '@/lib/tablePageSize' import { useCustomers } from '@/queries/organizations/customers' import { canManageCustomers, canDestroyCustomers } from '@/lib/permissions' import router from '@/router' +import UpdatingSpinner from '@/components/UpdatingSpinner.vue' const { isShownCreateCustomerDrawer = false } = defineProps<{ isShownCreateCustomerDrawer: boolean @@ -267,7 +267,7 @@ const goToCustomerDetails = (customer: Customer) => { />
-
+
@@ -284,7 +284,7 @@ const goToCustomerDetails = (customer: Customer) => { :label="t('common.status')" :options="statusFilterOptions" :show-clear-filter="false" - :clear-filter-label="t('ne_dropdown_filter.reset_filter')" + :clear-filter-label="t('ne_dropdown_filter.clear_filter')" :open-menu-aria-label="t('ne_dropdown_filter.open_filter')" :no-options-label="t('ne_dropdown_filter.no_options')" :more-options-hidden-label="t('ne_dropdown_filter.more_options_hidden')" @@ -309,15 +309,7 @@ const goToCustomerDetails = (customer: Customer) => {
-
- -
- {{ $t('common.updating') }} -
-
+
diff --git a/frontend/src/components/distributors/DistributorsTable.vue b/frontend/src/components/distributors/DistributorsTable.vue index 23d1c8a59..c3a58a033 100644 --- a/frontend/src/components/distributors/DistributorsTable.vue +++ b/frontend/src/components/distributors/DistributorsTable.vue @@ -33,7 +33,6 @@ import { NeEmptyState, NeInlineNotification, NeTextInput, - NeSpinner, NeDropdown, type SortEvent, NeSortDropdown, @@ -53,6 +52,7 @@ import { savePageSizeToStorage } from '@/lib/tablePageSize' import { useDistributors } from '@/queries/organizations/distributors' import { canDestroyDistributors, canManageDistributors } from '@/lib/permissions' import router from '@/router' +import UpdatingSpinner from '@/components/UpdatingSpinner.vue' const { isShownCreateDistributorDrawer = false } = defineProps<{ isShownCreateDistributorDrawer: boolean @@ -269,7 +269,7 @@ const goToDistributorDetails = (distributor: Distributor) => { />
-
+
@@ -286,7 +286,7 @@ const goToDistributorDetails = (distributor: Distributor) => { :label="t('common.status')" :options="statusFilterOptions" :show-clear-filter="false" - :clear-filter-label="t('ne_dropdown_filter.reset_filter')" + :clear-filter-label="t('ne_dropdown_filter.clear_filter')" :open-menu-aria-label="t('ne_dropdown_filter.open_filter')" :no-options-label="t('ne_dropdown_filter.no_options')" :more-options-hidden-label="t('ne_dropdown_filter.more_options_hidden')" @@ -311,15 +311,7 @@ const goToDistributorDetails = (distributor: Distributor) => {
-
- -
- {{ $t('common.updating') }} -
-
+
diff --git a/frontend/src/components/organizations/OrganizationApplicationsCard.vue b/frontend/src/components/organizations/OrganizationApplicationsCard.vue index f5d5c17b8..09ffbed2f 100644 --- a/frontend/src/components/organizations/OrganizationApplicationsCard.vue +++ b/frontend/src/components/organizations/OrganizationApplicationsCard.vue @@ -9,7 +9,7 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { faArrowRight } from '@fortawesome/free-solid-svg-icons' import { faGridOne } from '@nethesis/nethesis-solid-svg-icons' import CounterCard from '@/components/CounterCard.vue' -import { getApplicationLogo } from '@/lib/applications/applications' +import ApplicationLogo from '@/components/applications/ApplicationLogo.vue' import { useI18n } from 'vue-i18n' import { computed } from 'vue' import router from '@/router' @@ -65,13 +65,7 @@ const goToApplications = () => { class="flex items-center justify-between py-3" >
- + {{ appType.name || '-' }} diff --git a/frontend/src/components/organizations/OrganizationSystemsCard.vue b/frontend/src/components/organizations/OrganizationSystemsCard.vue index 05eb3ea15..49cf625ba 100644 --- a/frontend/src/components/organizations/OrganizationSystemsCard.vue +++ b/frontend/src/components/organizations/OrganizationSystemsCard.vue @@ -8,7 +8,7 @@ import { NeButton, NeLink } from '@nethesis/vue-components' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { faArrowRight, faServer } from '@fortawesome/free-solid-svg-icons' import CounterCard from '@/components/CounterCard.vue' -import { getProductLogo, getProductName } from '@/lib/systems/systems' +import SystemLogo from '@/components/systems/SystemLogo.vue' import SystemStatusIcon from '@/components/systems/SystemStatusIcon.vue' import { useI18n } from 'vue-i18n' import { computed } from 'vue' @@ -72,26 +72,17 @@ const goToSystems = () => { class="cursor-pointer font-medium hover:underline" >
- + {{ system.name || '-' }}
- - - {{ t('common.suspended') }} - - + -
diff --git a/frontend/src/components/resellers/ResellersTable.vue b/frontend/src/components/resellers/ResellersTable.vue index ebc3d4373..733c4cc39 100644 --- a/frontend/src/components/resellers/ResellersTable.vue +++ b/frontend/src/components/resellers/ResellersTable.vue @@ -32,7 +32,6 @@ import { NeEmptyState, NeInlineNotification, NeTextInput, - NeSpinner, NeDropdown, type SortEvent, NeSortDropdown, @@ -52,6 +51,7 @@ import { savePageSizeToStorage } from '@/lib/tablePageSize' import { useResellers } from '@/queries/organizations/resellers' import { canManageResellers, canDestroyResellers } from '@/lib/permissions' import router from '@/router' +import UpdatingSpinner from '@/components/UpdatingSpinner.vue' const { isShownCreateResellerDrawer = false } = defineProps<{ isShownCreateResellerDrawer: boolean @@ -268,7 +268,7 @@ const goToResellerDetails = (reseller: Reseller) => { />
-
+
@@ -285,7 +285,7 @@ const goToResellerDetails = (reseller: Reseller) => { :label="t('common.status')" :options="statusFilterOptions" :show-clear-filter="false" - :clear-filter-label="t('ne_dropdown_filter.reset_filter')" + :clear-filter-label="t('ne_dropdown_filter.clear_filter')" :open-menu-aria-label="t('ne_dropdown_filter.open_filter')" :no-options-label="t('ne_dropdown_filter.no_options')" :more-options-hidden-label="t('ne_dropdown_filter.more_options_hidden')" @@ -310,15 +310,7 @@ const goToResellerDetails = (reseller: Reseller) => {
-
- -
- {{ $t('common.updating') }} -
-
+
diff --git a/frontend/src/components/shell/TopBar.vue b/frontend/src/components/shell/TopBar.vue index 7edaf8ba7..ae414f168 100644 --- a/frontend/src/components/shell/TopBar.vue +++ b/frontend/src/components/shell/TopBar.vue @@ -80,7 +80,7 @@ function openNotificationsDrawer() { @@ -200,5 +304,31 @@ function getKebabMenuItems() { :current-system="systemDetail.data!" @close="isShownCreateOrEditSystemDrawer = false" /> + + + + + + + + diff --git a/frontend/src/components/systems/SystemLogo.vue b/frontend/src/components/systems/SystemLogo.vue new file mode 100644 index 000000000..a0a732752 --- /dev/null +++ b/frontend/src/components/systems/SystemLogo.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/frontend/src/components/systems/SystemOverviewPanel.vue b/frontend/src/components/systems/SystemOverviewPanel.vue index c975ad038..85bc92ca8 100644 --- a/frontend/src/components/systems/SystemOverviewPanel.vue +++ b/frontend/src/components/systems/SystemOverviewPanel.vue @@ -4,11 +4,20 @@ --> diff --git a/frontend/src/components/systems/SystemStatusCard.vue b/frontend/src/components/systems/SystemStatusCard.vue index a19223bfb..23dc9ef52 100644 --- a/frontend/src/components/systems/SystemStatusCard.vue +++ b/frontend/src/components/systems/SystemStatusCard.vue @@ -5,55 +5,66 @@ + + + + +
diff --git a/frontend/src/components/systems/SystemStatusIcon.vue b/frontend/src/components/systems/SystemStatusIcon.vue index 7ec7214cd..c21cec1ae 100644 --- a/frontend/src/components/systems/SystemStatusIcon.vue +++ b/frontend/src/components/systems/SystemStatusIcon.vue @@ -4,38 +4,39 @@ --> diff --git a/frontend/src/components/systems/SystemSubscriptionCard.vue b/frontend/src/components/systems/SystemSubscriptionCard.vue index 66dd7d22c..d9c49841d 100644 --- a/frontend/src/components/systems/SystemSubscriptionCard.vue +++ b/frontend/src/components/systems/SystemSubscriptionCard.vue @@ -74,27 +74,19 @@ function onCloseSecretRegeneratedModal() { />
- + - - - - - - + +
('') const statusFilterOptions = ref([ { - id: 'online', - label: t('systems.status_online'), + id: 'active', + label: t('systems.status_active'), }, { - id: 'offline', - label: t('systems.status_offline'), + id: 'inactive', + label: t('systems.status_inactive'), }, { id: 'unknown', @@ -388,7 +384,7 @@ function onCloseSecretRegeneratedModal() { />
-
+
@@ -487,15 +483,7 @@ function onCloseSecretRegeneratedModal() {
-
- -
- {{ $t('common.updating') }} -
-
+
@@ -560,26 +548,14 @@ function onCloseSecretRegeneratedModal() { class="cursor-pointer font-medium hover:underline" >
- + {{ item.name || '-' }}
- + {{ item.name || '-' }} @@ -653,14 +629,28 @@ function onCloseSecretRegeneratedModal() {
- - - {{ t('common.suspended') }} - - + - + + + + +
diff --git a/frontend/src/components/users/CreateOrEditUserDrawer.vue b/frontend/src/components/users/CreateOrEditUserDrawer.vue index 56ddf8edf..d102e879b 100644 --- a/frontend/src/components/users/CreateOrEditUserDrawer.vue +++ b/frontend/src/components/users/CreateOrEditUserDrawer.vue @@ -423,6 +423,8 @@ function getEmailInvalidMessage(): string { :label="$t('users.phone_number')" :invalid-message="validationIssues.phone?.[0] ? $t(validationIssues.phone[0]) : ''" :disabled="saving" + :optional="true" + :optional-label="t('common.optional')" /> { />
-
+
@@ -394,7 +394,7 @@ const onClosePasswordChangedModal = () => { :label="t('common.status')" :options="statusFilterOptions" :show-clear-filter="false" - :clear-filter-label="t('ne_dropdown_filter.reset_filter')" + :clear-filter-label="t('ne_dropdown_filter.clear_filter')" :open-menu-aria-label="t('ne_dropdown_filter.open_filter')" :no-options-label="t('ne_dropdown_filter.no_options')" :more-options-hidden-label="t('ne_dropdown_filter.more_options_hidden')" @@ -422,15 +422,7 @@ const onClosePasswordChangedModal = () => {
-
- -
- {{ $t('common.updating') }} -
-
+
diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index f8b0efae8..68da15ecd 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -30,10 +30,10 @@ "go_to_page": "Go to {page}", "open_page": "Open {page}", "actions": "Actions", - "object_created_successfully": "{name} created successfully", - "object_saved_successfully": "{name} saved successfully", - "object_destroyed_successfully": "{name} destroyed successfully", - "object_archived_successfully": "{name} archived successfully", + "object_created_successfully": "{name} has been created successfully", + "object_saved_successfully": "{name} has been updated successfully", + "object_destroyed_successfully": "{name} has been destroyed successfully", + "object_archived_successfully": "{name} has been archived successfully", "updating": "Updating...", "loading": "Loading...", "show": "Show", @@ -428,7 +428,9 @@ "filter_systems": "Filter systems", "product": "Product", "version": "Version", + "created": "Created", "created_by": "Created by", + "created_by_name": "Created by {name}", "organization": "Company", "organization_helper": "Choose the company this system will be assigned to", "status": "Status", @@ -456,9 +458,10 @@ "complete_the_subscription": "Complete the subscription", "system_secret_warning": "Copy the secret and paste it into the Subscription page of the system. You won't be able to see the secret again.", "copy_system_secret": "Copy system secret", - "status_online": "Active", - "status_offline": "Inactive", - "status_unknown": "Inventory not received", + "status_active": "Active", + "status_inactive": "Inactive", + "status_unknown": "Pending", + "status_suspended": "Suspended", "status_deleted": "Archived", "reset_filters": "Reset filters", "copy_and_close": "Copy and close", @@ -501,23 +504,64 @@ "system_detail": { "title": "System detail", "overview": "Overview", - "product_logo": "{product} logo", + "change_history": "Change history", "unknown_product": "Unknown product", "go_to_system": "Go to system", "go_to_system_tooltip": "Note: The system may be unreachable due to network settings.", "uptime": "Uptime", + "leader_node_uptime": "Leader node uptime", "last_inventory": "Last inventory", "timezone": "Timezone", + "leader_node_timezone": "Leader node timezone", "network": "Network", "dns_servers": "DNS servers", "cannot_retrieve_system_detail": "Cannot retrieve system detail", "cannot_retrieve_latest_inventory": "Cannot retrieve latest inventory", "no_inventory_available": "No inventory available", "no_inventory_available_description": "The system has not sent any inventory data yet.", + "total_changes": "Total changes", + "critical_changes": "Critical", + "high_changes": "High", + "medium_changes": "Medium", "subscription": "Subscription", - "system_creation": "System creation", + "active_since": "Active since", "system_key": "System key", - "cannot_determine_system_url_description": "Cannot access the system because its URL cannot be determined." + "cannot_determine_system_url_description": "Cannot access the system because its URL cannot be determined.", + "change_history_description": "The change history is based on the differences between the inventories sent by the system. If the system has not sent any inventory yet, no change history will be available.", + "today": "Today", + "no_changes_today": "No changes", + "one_day_no_changes": "1 day, no changes", + "n_days_no_changes": "{n} days, no changes", + "one_change": "1 change", + "n_changes": "{n} changes", + "severity": "Severity", + "category": "Category", + "change_type": "Change type", + "date_range": "Date range", + "cannot_retrieve_inventory_timeline": "Cannot retrieve inventory timeline", + "cannot_retrieve_inventory_diffs": "Cannot retrieve inventory diffs", + "previous_value": "Previous value", + "current_value": "Current value", + "diff_type_create": "Created", + "diff_type_update": "Updated", + "diff_type_delete": "Deleted", + "severity_critical": "Critical", + "severity_high": "High", + "severity_medium": "Medium", + "severity_low": "Low", + "category_os": "OS", + "category_hardware": "Hardware", + "category_network": "Network", + "category_security": "Security", + "category_backup": "Backup", + "category_features": "Features", + "category_modules": "Modules", + "category_cluster": "Cluster", + "category_nodes": "Nodes", + "category_system": "System", + "no_changes_in_timeline": "No change history", + "no_changes_in_timeline_description": "No changes match the current filters.", + "no_changes_in_timeline_no_filters_description": "The system has not reported any changes yet." }, "application_detail": { "title": "Application detail", @@ -603,7 +647,12 @@ "seconds": "{count} second | {count} seconds", "minutes": "{count} minute | {count} minutes", "hours": "{count} hour | {count} hours", - "days": "{count} day | {count} days" + "days": "{count} day | {count} days", + "weeks": "{count} week | {count} weeks", + "months": "{count} month | {count} months", + "years": "{count} year | {count} years", + "just_now": "Just now", + "ago": "{time} ago" }, "delete_object_modal": { "type_to_confirm": "Type '{confirmationText}' to confirm", diff --git a/frontend/src/i18n/it/translation.json b/frontend/src/i18n/it/translation.json index b0915ca4a..6b9244fbe 100644 --- a/frontend/src/i18n/it/translation.json +++ b/frontend/src/i18n/it/translation.json @@ -456,9 +456,9 @@ "complete_the_subscription": "Completa la subscription", "system_secret_warning": "Copia il segreto e incollalo nella pagina Subscription del sistema. Non sarai più in grado di visualizzare il segreto.", "copy_system_secret": "Copia segreto del sistema", - "status_online": "Attivo", - "status_offline": "Inattivo", - "status_unknown": "Inventario non ricevuto", + "status_active": "Attivo", + "status_inactive": "Inattivo", + "status_unknown": "In attesa", "status_deleted": "Archiviato", "reset_filters": "Reimposta filtri", "copy_and_close": "Copia e chiudi", @@ -501,7 +501,6 @@ "system_detail": { "title": "Dettaglio sistema", "overview": "Panoramica", - "product_logo": "Logo di {product}", "unknown_product": "Prodotto sconosciuto", "go_to_system": "Vai al sistema", "go_to_system_tooltip": "Nota: Il sistema potrebbe non essere raggiungibile a causa delle impostazioni di rete.", @@ -515,7 +514,7 @@ "no_inventory_available": "Nessun inventario disponibile", "no_inventory_available_description": "Il sistema non ha ancora inviato dati di inventario.", "subscription": "Subscription", - "system_creation": "Creazione sistema", + "active_since": "Attiva dal", "system_key": "Chiave di sistema", "cannot_determine_system_url_description": "Impossibile accedere al sistema perché non è possibile determinarne l'URL." }, @@ -603,7 +602,12 @@ "seconds": "{count} secondo | {count} secondi", "minutes": "{count} minuto | {count} minuti", "hours": "{count} ora | {count} ore", - "days": "{count} giorno | {count} giorni" + "days": "{count} giorno | {count} giorni", + "weeks": "{count} settimana | {count} settimane", + "months": "{count} mese | {count} mesi", + "years": "{count} anno | {count} anni", + "just_now": "Poco fa", + "ago": "{time} fa" }, "delete_object_modal": { "type_to_confirm": "Digita '{confirmationText}' per confermare", diff --git a/frontend/src/lib/account.ts b/frontend/src/lib/account.ts index 9d799d793..5641dad01 100644 --- a/frontend/src/lib/account.ts +++ b/frontend/src/lib/account.ts @@ -5,13 +5,12 @@ import axios from 'axios' import { API_URL } from './config' import { useLoginStore } from '@/stores/login' import * as v from 'valibot' +import { PhoneNumberSchema } from './users/users' export const ProfileInfoSchema = v.object({ name: v.pipe(v.string(), v.nonEmpty('users.name_cannot_be_empty')), email: v.pipe(v.string(), v.nonEmpty('users.email_required'), v.email('users.email_invalid')), - phone: v.optional( - v.pipe(v.string(), v.regex(/^\+?[\d\s\-\(\)]{7,20}$/, 'users.phone_invalid_format')), - ), + phone: v.optional(v.union([v.literal(''), PhoneNumberSchema])), }) export const ChangePasswordSchema = v.pipe( diff --git a/frontend/src/lib/applications/applications.ts b/frontend/src/lib/applications/applications.ts index b5e6de4ba..907fc4347 100644 --- a/frontend/src/lib/applications/applications.ts +++ b/frontend/src/lib/applications/applications.ts @@ -14,8 +14,6 @@ export const APPLICATIONS_TOTAL_KEY = 'applicationsTotal' export const APPLICATIONS_TABLE_ID = 'applicationsTable' export const SHOW_UNASSIGNED_APPS_NOTIFICATION = 'showUnassignedAppsNotification' -export type ApplicationStatus = 'online' | 'offline' | 'unknown' | 'deleted' - const applicationLogos = import.meta.glob('../../assets/application_logos/*.svg', { eager: true, import: 'default', diff --git a/frontend/src/lib/dateTime.test.ts b/frontend/src/lib/dateTime.test.ts index e35d07804..c0eec4a3e 100644 --- a/frontend/src/lib/dateTime.test.ts +++ b/frontend/src/lib/dateTime.test.ts @@ -6,21 +6,42 @@ import { formatDateTimeNoSeconds, formatMinutes, formatSeconds, + formatTimeAgo, formatUptime, } from './dateTime' -import { expect, it, describe, vi, beforeEach } from 'vitest' +import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest' // Create a simple mock function for translation -const mockT = vi.fn((key: string, count: number) => { +const mockT = vi.fn((key: string, countOrNamed?: number | Record) => { const translations: Record string> = { 'time.seconds': (count: number) => `${count} second${count !== 1 ? 's' : ''}`, 'time.minutes': (count: number) => `${count} minute${count !== 1 ? 's' : ''}`, 'time.hours': (count: number) => `${count} hour${count !== 1 ? 's' : ''}`, 'time.days': (count: number) => `${count} day${count !== 1 ? 's' : ''}`, + 'time.weeks': (count: number) => `${count} week${count !== 1 ? 's' : ''}`, + 'time.months': (count: number) => `${count} month${count !== 1 ? 's' : ''}`, + 'time.years': (count: number) => `${count} year${count !== 1 ? 's' : ''}`, } - if (translations[key]) { - return translations[key](count) + // Handle named parameter form: t('time.ago', { time: '...' }) + if (typeof countOrNamed === 'object' && countOrNamed !== null) { + if (key === 'time.ago') { + return `${countOrNamed.time} ago` + } + return key + } + + // Handle pluralization form: t('time.minutes', count) + if (typeof countOrNamed === 'number' && translations[key]) { + return translations[key](countOrNamed) + } + + // Handle simple keys + const simpleKeys: Record = { + 'time.just_now': 'Just now', + } + if (simpleKeys[key]) { + return simpleKeys[key] } return key @@ -67,7 +88,7 @@ describe('formatDateTimeNoSeconds', () => { expect(result).not.toContain('45') // Should contain year, month, day, hour, minute expect(result).toContain('2025') - expect(result).toContain('10') + expect(result).toContain('Oct') expect(result).toContain('03') expect(result).toContain('09') expect(result).toContain('30') @@ -90,7 +111,7 @@ describe('formatDateTimeNoSeconds', () => { expect(typeof result).toBe('string') expect(result).toContain('2025') - expect(result).toContain('10') + expect(result).toContain('Oct') expect(result).toContain('02') }) }) @@ -367,3 +388,118 @@ describe('formatUptime', () => { expect(result).toBe('59 minutes') }) }) + +describe('formatTimeAgo', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-03-12T12:00:00Z')) + mockT.mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should return "Just now" for dates less than 60 seconds ago', () => { + const result = formatTimeAgo('2026-03-12T11:59:30Z', mockT as any) // 30 seconds ago + expect(result).toBe('Just now') + }) + + it('should return "Just now" for dates exactly now', () => { + const result = formatTimeAgo('2026-03-12T12:00:00Z', mockT as any) + expect(result).toBe('Just now') + }) + + it('should return "Just now" for future dates', () => { + const result = formatTimeAgo('2026-03-12T13:00:00Z', mockT as any) + expect(result).toBe('Just now') + }) + + it('should return a dash for invalid date strings', () => { + const result = formatTimeAgo('not-a-date', mockT as any) + expect(result).toBe('-') + }) + + it('should return minutes ago for 1 minute', () => { + const result = formatTimeAgo('2026-03-12T11:59:00Z', mockT as any) // 60 seconds ago + expect(result).toBe('1 minute ago') + }) + + it('should return minutes ago for multiple minutes', () => { + const result = formatTimeAgo('2026-03-12T11:51:00Z', mockT as any) // 9 minutes ago + expect(result).toBe('9 minutes ago') + }) + + it('should return minutes ago for 59 minutes', () => { + const result = formatTimeAgo('2026-03-12T11:01:00Z', mockT as any) // 59 minutes ago + expect(result).toBe('59 minutes ago') + }) + + it('should return hours ago for 1 hour', () => { + const result = formatTimeAgo('2026-03-12T11:00:00Z', mockT as any) // 1 hour ago + expect(result).toBe('1 hour ago') + }) + + it('should return hours ago for multiple hours', () => { + const result = formatTimeAgo('2026-03-12T09:00:00Z', mockT as any) // 3 hours ago + expect(result).toBe('3 hours ago') + }) + + it('should return days ago for 1 day', () => { + const result = formatTimeAgo('2026-03-11T12:00:00Z', mockT as any) // 1 day ago + expect(result).toBe('1 day ago') + }) + + it('should return days ago for multiple days', () => { + const result = formatTimeAgo('2026-03-10T12:00:00Z', mockT as any) // 2 days ago + expect(result).toBe('2 days ago') + }) + + it('should return weeks ago for 1 week', () => { + const result = formatTimeAgo('2026-03-05T12:00:00Z', mockT as any) // 7 days ago + expect(result).toBe('1 week ago') + }) + + it('should return weeks ago for multiple weeks', () => { + const result = formatTimeAgo('2026-02-26T12:00:00Z', mockT as any) // 14 days ago + expect(result).toBe('2 weeks ago') + }) + + it('should return months ago for 1 month', () => { + const result = formatTimeAgo('2026-02-10T12:00:00Z', mockT as any) // 30 days ago + expect(result).toBe('1 month ago') + }) + + it('should return months ago for multiple months', () => { + const result = formatTimeAgo('2025-12-12T12:00:00Z', mockT as any) // 90 days ago + expect(result).toBe('3 months ago') + }) + + it('should return years ago for 1 year', () => { + const result = formatTimeAgo('2025-03-12T12:00:00Z', mockT as any) // 365 days ago + expect(result).toBe('1 year ago') + }) + + it('should return years ago for multiple years', () => { + const result = formatTimeAgo('2024-03-12T12:00:00Z', mockT as any) // ~730 days ago + expect(result).toBe('2 years ago') + }) + + it('should return duration without suffix when suffix is false', () => { + const result = formatTimeAgo('2026-03-12T09:00:00Z', mockT as any, { suffix: false }) + expect(result).toBe('3 hours') + }) + + it('should return "Just now" even when suffix is false', () => { + const result = formatTimeAgo('2026-03-12T11:59:30Z', mockT as any, { suffix: false }) + expect(result).toBe('Just now') + }) + + it('should return duration without suffix for each unit', () => { + expect(formatTimeAgo('2026-03-12T11:51:00Z', mockT as any, { suffix: false })).toBe('9 minutes') + expect(formatTimeAgo('2026-03-11T12:00:00Z', mockT as any, { suffix: false })).toBe('1 day') + expect(formatTimeAgo('2026-03-05T12:00:00Z', mockT as any, { suffix: false })).toBe('1 week') + expect(formatTimeAgo('2026-02-10T12:00:00Z', mockT as any, { suffix: false })).toBe('1 month') + expect(formatTimeAgo('2025-03-12T12:00:00Z', mockT as any, { suffix: false })).toBe('1 year') + }) +}) diff --git a/frontend/src/lib/dateTime.ts b/frontend/src/lib/dateTime.ts index 7f5511b6a..825f2d75e 100644 --- a/frontend/src/lib/dateTime.ts +++ b/frontend/src/lib/dateTime.ts @@ -10,7 +10,7 @@ export function formatDateTime(dateTime: Date, locale: string): string { export function formatDateTimeNoSeconds(dateTime: Date, locale: string): string { return dateTime.toLocaleString(locale, { year: 'numeric', - month: '2-digit', + month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', @@ -37,7 +37,7 @@ export function formatSeconds(totalSeconds: number, t: ComposerTranslation) { return t('time.seconds', totalSeconds) } - if (totalSeconds < 3600) { + if (totalSeconds < 60 * 60) { const minutes = Math.floor(totalSeconds / 60) const seconds = totalSeconds % 60 @@ -48,8 +48,8 @@ export function formatSeconds(totalSeconds: number, t: ComposerTranslation) { return `${t('time.minutes', minutes)}, ${t('time.seconds', seconds)}` } - const hours = Math.floor(totalSeconds / 3600) - const minutes = Math.floor((totalSeconds % 3600) / 60) + const hours = Math.floor(totalSeconds / (60 * 60)) + const minutes = Math.floor((totalSeconds % (60 * 60)) / 60) const seconds = totalSeconds % 60 if (minutes === 0 && seconds === 0) { @@ -75,24 +75,81 @@ export function formatUptime(uptimeSeconds: number, t: ComposerTranslation): str return t('time.seconds', uptimeSeconds) } - if (uptimeSeconds < 3600) { + if (uptimeSeconds < 60 * 60) { const minutes = Math.floor(uptimeSeconds / 60) return t('time.minutes', minutes) } - if (uptimeSeconds < 86400) { - const hours = Math.floor(uptimeSeconds / 3600) - const minutes = Math.floor((uptimeSeconds % 3600) / 60) + if (uptimeSeconds < 60 * 60 * 24) { + const hours = Math.floor(uptimeSeconds / (60 * 60)) + const minutes = Math.floor((uptimeSeconds % (60 * 60)) / 60) if (minutes === 0) { return t('time.hours', hours) } return `${t('time.hours', hours)}, ${t('time.minutes', minutes)}` } - const days = Math.floor(uptimeSeconds / 86400) - const hours = Math.floor((uptimeSeconds % 86400) / 3600) + const days = Math.floor(uptimeSeconds / (60 * 60 * 24)) + const hours = Math.floor((uptimeSeconds % (60 * 60 * 24)) / (60 * 60)) if (hours === 0) { return t('time.days', days) } return `${t('time.days', days)}, ${t('time.hours', hours)}` } + +/** + * Format an ISO date string as a human-readable relative time string + * (e.g. "3 hours ago", "Just now") + * + * @param isoDate - ISO 8601 date string + * @param t - vue-i18n translation function + * @param options.suffix - whether to wrap the duration with the "ago" suffix (default: true) + */ +export function formatTimeAgo( + isoDate: string, + t: ComposerTranslation, + options: { suffix?: boolean } = {}, +): string { + const { suffix = true } = options + const date = new Date(isoDate) + + if (isNaN(date.getTime())) { + return '-' + } + + const diffSeconds = Math.floor((Date.now() - date.getTime()) / 1000) + + if (diffSeconds < 60) { + return t('time.just_now') + } + + const formatElapsed = (time: string) => (suffix ? t('time.ago', { time }) : time) + + if (diffSeconds < 60 * 60) { + const minutes = Math.floor(diffSeconds / 60) + return formatElapsed(t('time.minutes', minutes)) + } + + if (diffSeconds < 60 * 60 * 24) { + const hours = Math.floor(diffSeconds / (60 * 60)) + return formatElapsed(t('time.hours', hours)) + } + + if (diffSeconds < 60 * 60 * 24 * 7) { + const days = Math.floor(diffSeconds / (60 * 60 * 24)) + return formatElapsed(t('time.days', days)) + } + + if (diffSeconds < 60 * 60 * 24 * 30) { + const weeks = Math.floor(diffSeconds / (60 * 60 * 24 * 7)) + return formatElapsed(t('time.weeks', weeks)) + } + + if (diffSeconds < 60 * 60 * 24 * 365) { + const months = Math.floor(diffSeconds / (60 * 60 * 24 * 30)) + return formatElapsed(t('time.months', months)) + } + + const years = Math.floor(diffSeconds / (60 * 60 * 24 * 365)) + return formatElapsed(t('time.years', years)) +} diff --git a/frontend/src/lib/systems/inventory.ts b/frontend/src/lib/systems/inventory.ts index de5801ae5..7ef40e1ac 100644 --- a/frontend/src/lib/systems/inventory.ts +++ b/frontend/src/lib/systems/inventory.ts @@ -12,9 +12,103 @@ interface InventoryData { system_id: string timestamp: string // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: any // The structure of inventory data can be complex and varied + data: any //// improve typing } +//// move specific facts to separate files? + +// interface NsecFacts { //// +// distro: { +// name: string +// version: string +// } +// memory: { +// // swap: { //// +// // used_bytes: number +// // available_bytes: number +// // } +// // ... +// } +// features: NsecFeatures +// } + +// interface NsecFeatures { +// ha: { +// vips: number +// enabled: boolean +// } +// ui: { +// luci: boolean +// port443: boolean +// port9090: boolean +// } +// dpi: { +// rules: number +// enabled: boolean +// } +// qos: { +// count: number +// rules: unknown[] +// } +// ddns: { +// enabled: boolean +// } +// snmp: { +// enabled: boolean +// } +// ipsec: { +// count: number +// } +// snort: { +// policy: string +// enabled: boolean +// oink_enabled: boolean +// disabled_rules: number +// bypass_dst_ipv4: number +// bypass_dst_ipv6: number +// bypass_src_ipv4: number +// bypass_src_ipv6: number +// suppressed_rules: number +// } +// adblock: { +// enabled: boolean +// community: number +// enterprise: number +// } +// backups: { +// passphrase_date: number +// backup_passphrase: boolean +// } +// hotspot: { +// server: string +// enabled: boolean +// interface: string +// } +// netifyd: { +// enabled: boolean +// } +// network: NsecNetworkFeature +// } + +// interface NsecNetworkFeature { +// zones: { +// ipv4: number +// ipv6: number +// name: string +// }[] +// route_info: { +// count_ipv4_route: number +// count_ipv6_route: number +// } +// interface_counts: { +// bonds: number +// vlans: number +// bridges: number +// } +// zone_network_counts: Record +// } + +//// fix network card, then remove export interface EsmithConfiguration { name: string type: string diff --git a/frontend/src/lib/systems/inventoryChanges.ts b/frontend/src/lib/systems/inventoryChanges.ts new file mode 100644 index 000000000..909f6181a --- /dev/null +++ b/frontend/src/lib/systems/inventoryChanges.ts @@ -0,0 +1,36 @@ +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import axios from 'axios' +import { API_URL } from '../config' +import { useLoginStore } from '@/stores/login' +import { type InventoryDiffCategory, type InventoryDiffSeverity } from './inventoryDiffs' + +export const INVENTORY_CHANGES_KEY = 'inventoryChanges' + +export interface InventoryChanges { + system_id: string + total_changes: number + recent_changes: number + last_inventory_time: string + has_critical_changes: boolean + has_alerts: boolean + changes_by_category: Partial> + changes_by_severity: Partial> +} + +interface InventoryChangesResponse { + code: number + message: string + data: InventoryChanges | null +} + +export const getInventoryChanges = (systemId: string) => { + const loginStore = useLoginStore() + + return axios + .get(`${API_URL}/systems/${systemId}/inventory/changes`, { + headers: { Authorization: `Bearer ${loginStore.jwtToken}` }, + }) + .then((res) => res.data.data) +} diff --git a/frontend/src/lib/systems/inventoryDiffs.ts b/frontend/src/lib/systems/inventoryDiffs.ts new file mode 100644 index 000000000..e2cfe4429 --- /dev/null +++ b/frontend/src/lib/systems/inventoryDiffs.ts @@ -0,0 +1,118 @@ +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import axios from 'axios' +import { API_URL } from '../config' +import { useLoginStore } from '@/stores/login' +import { type Pagination } from '../common' + +export const INVENTORY_DIFFS_KEY = 'inventoryDiffs' +export const INVENTORY_DIFFS_TABLE_ID = 'inventoryDiffsTable' + +export type InventoryDiffSeverity = 'low' | 'medium' | 'high' | 'critical' + +export type InventoryDiffCategory = + | 'os' + | 'hardware' + | 'network' + | 'security' + | 'backup' + | 'features' + | 'modules' + | 'cluster' + | 'nodes' + | 'system' + +export type InventoryDiffType = 'create' | 'update' | 'delete' + +export interface InventoryDiff { + id: number + system_id: string + previous_inventory_id: number | null + inventory_id: number + diff_type: InventoryDiffType + field_path: string + previous_value: unknown + current_value: unknown + severity: InventoryDiffSeverity + category: InventoryDiffCategory + notification_sent: boolean + created_at: string +} + +interface InventoryDiffsResponse { + code: number + message: string + data: { + diffs: InventoryDiff[] + pagination: Pagination + } +} + +const getInventoryDiffsQueryStringParams = ( + pageNum: number, + pageSize: number, + severity: InventoryDiffSeverity[], + category: InventoryDiffCategory[], + diffType: InventoryDiffType[], + inventoryId: number[], + fromDate: string, + toDate: string, + search: string, +) => { + const searchParams = new URLSearchParams({ + page: pageNum.toString(), + page_size: pageSize.toString(), + }) + + severity.forEach((s) => searchParams.append('severity', s)) + category.forEach((c) => searchParams.append('category', c)) + diffType.forEach((d) => searchParams.append('diff_type', d)) + inventoryId.forEach((id) => searchParams.append('inventory_id', id.toString())) + + if (fromDate.trim()) { + searchParams.append('from_date', fromDate + 'T00:00:00Z') + } + + if (toDate.trim()) { + searchParams.append('to_date', toDate + 'T23:59:59Z') + } + + if (search.trim()) { + searchParams.append('search', search) + } + + return searchParams.toString() +} + +export const getInventoryDiffs = ( + systemId: string, + pageNum: number, + pageSize: number, + severity: InventoryDiffSeverity[], + category: InventoryDiffCategory[], + diffType: InventoryDiffType[], + inventoryId: number[], + fromDate: string, + toDate: string, + search: string, +) => { + const loginStore = useLoginStore() + const queryString = getInventoryDiffsQueryStringParams( + pageNum, + pageSize, + severity, + category, + diffType, + inventoryId, + fromDate, + toDate, + search, + ) + + return axios + .get(`${API_URL}/systems/${systemId}/inventory/diffs?${queryString}`, { + headers: { Authorization: `Bearer ${loginStore.jwtToken}` }, + }) + .then((res) => res.data.data) +} diff --git a/frontend/src/lib/systems/inventoryTimeline.ts b/frontend/src/lib/systems/inventoryTimeline.ts new file mode 100644 index 000000000..8a4eb87d9 --- /dev/null +++ b/frontend/src/lib/systems/inventoryTimeline.ts @@ -0,0 +1,107 @@ +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import axios from 'axios' +import { API_URL } from '../config' +import { useLoginStore } from '@/stores/login' +import { type Pagination } from '../common' +import { + type InventoryDiffCategory, + type InventoryDiffSeverity, + type InventoryDiffType, +} from './inventoryDiffs' + +export const INVENTORY_TIMELINE_KEY = 'inventoryTimeline' +export const INVENTORY_TIMELINE_TABLE_ID = 'inventoryTimelineTable' + +export interface InventoryTimelineSummary { + total: number + critical: number + high: number + medium: number + low: number +} + +export interface InventoryTimelineGroup { + date: string + inventory_count: number + change_count: number + inventory_ids: number[] +} + +interface InventoryTimelineResponse { + code: number + message: string + data: { + summary: InventoryTimelineSummary + groups: InventoryTimelineGroup[] + pagination: Pagination + } +} + +const getInventoryTimelineQueryStringParams = ( + pageNum: number, + pageSize: number, + severity: InventoryDiffSeverity[], + category: InventoryDiffCategory[], + diffType: InventoryDiffType[], + fromDate: string, + toDate: string, + search: string, +) => { + const searchParams = new URLSearchParams({ + page: pageNum.toString(), + page_size: pageSize.toString(), + }) + + severity.forEach((s) => searchParams.append('severity', s)) + category.forEach((c) => searchParams.append('category', c)) + diffType.forEach((d) => searchParams.append('diff_type', d)) + + if (fromDate.trim()) { + searchParams.append('from_date', fromDate + 'T00:00:00Z') + } + + if (toDate.trim()) { + searchParams.append('to_date', toDate + 'T23:59:59Z') + } + + if (search.trim()) { + searchParams.append('search', search) + } + + return searchParams.toString() +} + +export const getInventoryTimeline = ( + systemId: string, + pageNum: number, + pageSize: number, + severity: InventoryDiffSeverity[], + category: InventoryDiffCategory[], + diffType: InventoryDiffType[], + fromDate: string, + toDate: string, + search: string, +) => { + const loginStore = useLoginStore() + const queryString = getInventoryTimelineQueryStringParams( + pageNum, + pageSize, + severity, + category, + diffType, + fromDate, + toDate, + search, + ) + + return axios + .get( + `${API_URL}/systems/${systemId}/inventory/timeline?${queryString}`, + { + headers: { Authorization: `Bearer ${loginStore.jwtToken}` }, + }, + ) + .then((res) => res.data.data) +} diff --git a/frontend/src/lib/systems/systems.ts b/frontend/src/lib/systems/systems.ts index c5faa2e48..f4f46acb8 100644 --- a/frontend/src/lib/systems/systems.ts +++ b/frontend/src/lib/systems/systems.ts @@ -13,11 +13,7 @@ export const SYSTEMS_KEY = 'systems' export const SYSTEMS_TOTAL_KEY = 'systemsTotal' export const SYSTEMS_TABLE_ID = 'systemsTable' -export type SystemStatus = 'online' | 'offline' | 'unknown' | 'deleted' | 'suspended' - -const systemStatusOptions = ['online', 'offline', 'unknown', 'deleted', 'suspended'] - -const SystemStatusSchema = v.picklist(systemStatusOptions) +const SystemStatusSchema = v.picklist(['active', 'inactive', 'unknown', 'deleted', 'suspended']) export const CreateSystemSchema = v.object({ name: v.pipe(v.string(), v.nonEmpty('systems.name_cannot_be_empty')), @@ -42,11 +38,15 @@ export const SystemSchema = v.object({ version: v.string(), created_at: v.string(), updated_at: v.string(), + registered_at: v.optional(v.string()), system_key: v.optional(v.string()), system_secret: v.string(), suspended_at: v.optional(v.string()), + last_inventory: v.optional(v.string()), + rebranding_enabled: v.optional(v.boolean()), organization: v.object({ id: v.string(), + logto_id: v.string(), name: v.string(), type: v.string(), }), @@ -63,6 +63,7 @@ export const SystemSchema = v.object({ export type CreateSystem = v.InferOutput export type EditSystem = v.InferOutput export type System = v.InferOutput +export type SystemStatus = v.InferOutput interface SystemsResponse { code: number diff --git a/frontend/src/lib/users/users.ts b/frontend/src/lib/users/users.ts index a71ac9d2e..7c2d2a1ac 100644 --- a/frontend/src/lib/users/users.ts +++ b/frontend/src/lib/users/users.ts @@ -13,15 +13,15 @@ export const USERS_TABLE_ID = 'usersTable' export type UserStatus = 'enabled' | 'suspended' | 'deleted' +export const PhoneNumberSchema = v.pipe( + v.string(), + v.regex(/^\+?[\d\s\-\(\)]{7,20}$/, 'users.phone_invalid_format'), +) + export const CreateUserSchema = v.object({ email: v.pipe(v.string(), v.nonEmpty('users.email_required'), v.email('users.email_invalid')), name: v.pipe(v.string(), v.nonEmpty('users.name_cannot_be_empty')), - phone: v.optional( - v.union([ - v.literal(''), - v.pipe(v.string(), v.regex(/^\+?[\d\s\-\(\)]{7,20}$/, 'users.phone_invalid_format')), - ]), - ), + phone: v.optional(v.union([v.literal(''), PhoneNumberSchema])), user_role_ids: v.pipe( v.array(v.string()), v.minLength(1, 'users.user_role_ids_at_least_one_role_is_required'), diff --git a/frontend/src/queries/systems/inventoryChanges.ts b/frontend/src/queries/systems/inventoryChanges.ts new file mode 100644 index 000000000..427cc5b9d --- /dev/null +++ b/frontend/src/queries/systems/inventoryChanges.ts @@ -0,0 +1,28 @@ +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import { getInventoryChanges, INVENTORY_CHANGES_KEY } from '@/lib/systems/inventoryChanges' +import { canReadSystems } from '@/lib/permissions' +import { useLoginStore } from '@/stores/login' +import { defineQuery, useQuery } from '@pinia/colada' +import { useRoute } from 'vue-router' + +export const useInventoryChanges = defineQuery(() => { + const loginStore = useLoginStore() + const route = useRoute() + + const { state, asyncStatus, ...rest } = useQuery({ + key: () => [INVENTORY_CHANGES_KEY, route.params.systemId], + enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId, + query: () => { + const apiCall = getInventoryChanges(route.params.systemId as string) + return apiCall + }, + }) + + return { + ...rest, + state, + asyncStatus, + } +}) diff --git a/frontend/src/queries/systems/inventoryDiffs.ts b/frontend/src/queries/systems/inventoryDiffs.ts new file mode 100644 index 000000000..3e6c49096 --- /dev/null +++ b/frontend/src/queries/systems/inventoryDiffs.ts @@ -0,0 +1,188 @@ +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import { + INVENTORY_DIFFS_KEY, + INVENTORY_DIFFS_TABLE_ID, + getInventoryDiffs, + type InventoryDiffCategory, + type InventoryDiffSeverity, + type InventoryDiffType, +} from '@/lib/systems/inventoryDiffs' +import { MIN_SEARCH_LENGTH } from '@/lib/common' +import { canReadSystems } from '@/lib/permissions' +import { DEFAULT_PAGE_SIZE, loadPageSizeFromStorage } from '@/lib/tablePageSize' +import { useLoginStore } from '@/stores/login' +import { defineQuery, useQuery } from '@pinia/colada' +import { useDebounceFn } from '@vueuse/core' +import { computed, ref, watch } from 'vue' +import { useRoute } from 'vue-router' + +//// currently unused? +export const useInventoryDiffs = defineQuery(() => { + const loginStore = useLoginStore() + const route = useRoute() + const pageNum = ref(1) + const pageSize = ref(DEFAULT_PAGE_SIZE) + const severityFilter = ref([]) + const categoryFilter = ref([]) + const diffTypeFilter = ref([]) + const inventoryIdFilter = ref([]) + const fromDate = ref('') + const toDate = ref('') + const textFilter = ref('') + const debouncedTextFilter = ref('') + + const { state, asyncStatus, ...rest } = useQuery({ + key: () => [ + INVENTORY_DIFFS_KEY, + { + systemId: route.params.systemId, + pageNum: pageNum.value, + pageSize: pageSize.value, + severityFilter: severityFilter.value, + categoryFilter: categoryFilter.value, + diffTypeFilter: diffTypeFilter.value, + inventoryIdFilter: inventoryIdFilter.value, + fromDate: fromDate.value, + toDate: toDate.value, + search: debouncedTextFilter.value, + }, + ], + enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId, + staleTime: 0, + gcTime: 0, + query: () => { + const apiCall = getInventoryDiffs( + route.params.systemId as string, + pageNum.value, + pageSize.value, + severityFilter.value, + categoryFilter.value, + diffTypeFilter.value, + inventoryIdFilter.value, + fromDate.value, + toDate.value, + debouncedTextFilter.value, + ) + return apiCall + }, + }) + + const areDefaultFiltersApplied = computed(() => { + return ( + severityFilter.value.length === 0 && + categoryFilter.value.length === 0 && + diffTypeFilter.value.length === 0 && + inventoryIdFilter.value.length === 0 && + !fromDate.value && + !toDate.value && + !debouncedTextFilter.value + ) + }) + + // load table page size from storage + watch( + () => loginStore.userInfo?.email, + (email) => { + if (email) { + pageSize.value = loadPageSizeFromStorage(INVENTORY_DIFFS_TABLE_ID) + } + }, + { immediate: true }, + ) + + // reset to first page when page size changes + watch( + () => pageSize.value, + () => { + pageNum.value = 1 + }, + ) + + // reset to first page when any filter changes + watch( + () => severityFilter.value, + () => { + pageNum.value = 1 + }, + { deep: true }, + ) + + watch( + () => categoryFilter.value, + () => { + pageNum.value = 1 + }, + { deep: true }, + ) + + watch( + () => diffTypeFilter.value, + () => { + pageNum.value = 1 + }, + { deep: true }, + ) + + watch( + () => inventoryIdFilter.value, + () => { + pageNum.value = 1 + }, + { deep: true }, + ) + + watch( + () => fromDate.value, + () => { + pageNum.value = 1 + }, + ) + + watch( + () => toDate.value, + () => { + pageNum.value = 1 + }, + ) + + const resetFilters = () => { + severityFilter.value = [] + categoryFilter.value = [] + diffTypeFilter.value = [] + inventoryIdFilter.value = [] + fromDate.value = '' + toDate.value = '' + textFilter.value = '' + debouncedTextFilter.value = '' + } + + watch( + () => textFilter.value, + useDebounceFn(() => { + if (textFilter.value.length === 0 || textFilter.value.length >= MIN_SEARCH_LENGTH) { + debouncedTextFilter.value = textFilter.value + pageNum.value = 1 + } + }, 500), + ) + + return { + ...rest, + state, + asyncStatus, + pageNum, + pageSize, + severityFilter, + categoryFilter, + diffTypeFilter, + inventoryIdFilter, + fromDate, + toDate, + textFilter, + debouncedTextFilter, + areDefaultFiltersApplied, + resetFilters, + } +}) diff --git a/frontend/src/queries/systems/inventoryTimeline.ts b/frontend/src/queries/systems/inventoryTimeline.ts new file mode 100644 index 000000000..5a5ba5146 --- /dev/null +++ b/frontend/src/queries/systems/inventoryTimeline.ts @@ -0,0 +1,125 @@ +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import { + type InventoryDiffCategory, + type InventoryDiffSeverity, + type InventoryDiffType, +} from '@/lib/systems/inventoryDiffs' +import { INVENTORY_TIMELINE_KEY, getInventoryTimeline } from '@/lib/systems/inventoryTimeline' +import { MIN_SEARCH_LENGTH } from '@/lib/common' +import { canReadSystems } from '@/lib/permissions' +import { useLoginStore } from '@/stores/login' +import { defineQuery, useInfiniteQuery } from '@pinia/colada' +import { useDebounceFn } from '@vueuse/core' +import { computed, ref, watch } from 'vue' +import { useRoute } from 'vue-router' + +const TIMELINE_PAGE_SIZE = 5 //// 20 + +export const useInventoryTimeline = defineQuery(() => { + const loginStore = useLoginStore() + const route = useRoute() + const severityFilter = ref([]) + const categoryFilter = ref([]) + const diffTypeFilter = ref([]) + const fromDate = ref('') + const toDate = ref('') + const textFilter = ref('') + const debouncedTextFilter = ref('') + + const { state, asyncStatus, hasNextPage, loadNextPage } = useInfiniteQuery({ + key: () => [ + INVENTORY_TIMELINE_KEY, + { + systemId: route.params.systemId, + severityFilter: severityFilter.value, + categoryFilter: categoryFilter.value, + diffTypeFilter: diffTypeFilter.value, + fromDate: fromDate.value, + toDate: toDate.value, + search: debouncedTextFilter.value, + }, + ], + enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId, + staleTime: 0, + gcTime: 0, + initialPageParam: 1, + query: ({ pageParam }) => { + const apiCall = getInventoryTimeline( + route.params.systemId as string, + pageParam, + TIMELINE_PAGE_SIZE, + severityFilter.value, + categoryFilter.value, + diffTypeFilter.value, + fromDate.value, + toDate.value, + debouncedTextFilter.value, + ) + return apiCall + }, + getNextPageParam: (lastPage) => + lastPage.pagination.has_next ? lastPage.pagination.page + 1 : null, + }) + + const allInventoryIds = computed(() => + (state.value.data?.pages ?? []).flatMap((page) => + page.groups.flatMap((group) => group.inventory_ids), + ), + ) + + const allGroups = computed(() => (state.value.data?.pages ?? []).flatMap((page) => page.groups)) + + // Summary from the first page — represents overall totals for the current system + const summary = computed(() => state.value.data?.pages[0]?.summary ?? null) + + const areDefaultFiltersApplied = computed(() => { + return ( + severityFilter.value.length === 0 && + categoryFilter.value.length === 0 && + diffTypeFilter.value.length === 0 && + !fromDate.value && + !toDate.value && + !debouncedTextFilter.value + ) + }) + + watch( + () => textFilter.value, + useDebounceFn(() => { + if (textFilter.value.length === 0 || textFilter.value.length >= MIN_SEARCH_LENGTH) { + debouncedTextFilter.value = textFilter.value + } + }, 500), + ) + + const resetFilters = () => { + severityFilter.value = [] + categoryFilter.value = [] + diffTypeFilter.value = [] + fromDate.value = '' + toDate.value = '' + textFilter.value = '' + debouncedTextFilter.value = '' + } + + return { + state, + asyncStatus, + hasNextPage, + loadNextPage, + severityFilter, + categoryFilter, + diffTypeFilter, + fromDate, + toDate, + textFilter, + debouncedTextFilter, + areDefaultFiltersApplied, + resetFilters, + allInventoryIds, + allGroups, + summary, + } +}) diff --git a/frontend/src/queries/systems/systems.ts b/frontend/src/queries/systems/systems.ts index e51d0eded..4eb1292c6 100644 --- a/frontend/src/queries/systems/systems.ts +++ b/frontend/src/queries/systems/systems.ts @@ -26,7 +26,7 @@ export const useSystems = defineQuery(() => { const productFilter = ref([]) const createdByFilter = ref([]) const versionFilter = ref([]) - const statusFilter = ref(['online', 'offline', 'unknown', 'suspended']) + const statusFilter = ref(['active', 'inactive', 'unknown', 'suspended']) const organizationFilter = ref([]) const sortBy = ref('name') const sortDescending = ref(false) @@ -71,8 +71,8 @@ export const useSystems = defineQuery(() => { createdByFilter.value.length === 0 && organizationFilter.value.length === 0 && statusFilter.value.length === 4 && - statusFilter.value.includes('online') && - statusFilter.value.includes('offline') && + statusFilter.value.includes('active') && + statusFilter.value.includes('inactive') && statusFilter.value.includes('unknown') && statusFilter.value.includes('suspended') && !statusFilter.value.includes('deleted') @@ -156,7 +156,7 @@ export const useSystems = defineQuery(() => { productFilter.value = [] versionFilter.value = [] createdByFilter.value = [] - statusFilter.value = ['online', 'offline', 'unknown', 'suspended'] + statusFilter.value = ['active', 'inactive', 'unknown', 'suspended'] organizationFilter.value = [] } diff --git a/frontend/src/views/SystemDetailView.vue b/frontend/src/views/SystemDetailView.vue index 47571509c..59b529c85 100644 --- a/frontend/src/views/SystemDetailView.vue +++ b/frontend/src/views/SystemDetailView.vue @@ -18,13 +18,17 @@ import { useSystemDetail } from '@/queries/systems/systemDetail' import { useTabs } from '@/composables/useTabs' import { useI18n } from 'vue-i18n' import SystemOverviewPanel from '@/components/systems/SystemOverviewPanel.vue' +import SystemChangeHistoryPanel from '@/components/systems/SystemChangeHistoryPanel.vue' import { useLatestInventory } from '@/queries/systems/latestInventory' import { computed } from 'vue' const { t } = useI18n() const { state: systemDetail } = useSystemDetail() const { state: latestInventory } = useLatestInventory() -const { tabs, selectedTab } = useTabs([{ name: 'overview', label: t('system_detail.overview') }]) +const { tabs, selectedTab } = useTabs([ + { name: 'overview', label: t('system_detail.overview') }, + { name: 'change_history', label: t('system_detail.change_history') }, +]) const systemUrl = computed(() => { if (!systemDetail.value.data?.fqdn) { @@ -114,5 +118,6 @@ const openSystem = () => { @select-tab="selectedTab = $event" /> +
diff --git a/proxy/.render-build-trigger b/proxy/.render-build-trigger index 3bb7a5909..89e6edccd 100644 --- a/proxy/.render-build-trigger +++ b/proxy/.render-build-trigger @@ -2,9 +2,9 @@ # This file is used to force Docker service rebuilds in PR previews # Modify LAST_UPDATE to trigger rebuilds -LAST_UPDATE=2026-02-10T12:10:22Z +LAST_UPDATE=2026-03-09T09:01:19Z # Instructions: # 1. To force rebuild of Docker services in a PR, update LAST_UPDATE -# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-02-10T12:10:22Z +# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-03-09T09:01:19Z # 2. Commit and push changes to trigger Docker rebuilds \ No newline at end of file diff --git a/services/mimir/.render-build-trigger b/services/mimir/.render-build-trigger index 9249691c6..f4eef0389 100644 --- a/services/mimir/.render-build-trigger +++ b/services/mimir/.render-build-trigger @@ -2,7 +2,7 @@ # This file is used to force Docker service rebuilds in PR previews # Modify LAST_UPDATE to trigger rebuilds -LAST_UPDATE=2025-10-22T15:22:55Z +LAST_UPDATE=2026-03-09T09:01:19Z # Instructions: # 1. To force rebuild of Docker services in a PR, update LAST_UPDATE