From 8e47da2bcc69ca97c7a32d56986a88fa801dbb2e Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Thu, 5 Mar 2026 17:31:39 +0100 Subject: [PATCH 01/31] fix devcontainer name and package-lock version --- .devcontainer/devcontainer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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": { From f48d95049923d2fcd70f5fc4d885559a52dd10cb Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Thu, 5 Mar 2026 18:23:07 +0100 Subject: [PATCH 02/31] Fix topbar shadow --- frontend/src/components/shell/TopBar.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() { - + - + diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 999dff79e..4a6f191df 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -506,8 +506,10 @@ "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", diff --git a/frontend/src/lib/systems/inventory.ts b/frontend/src/lib/systems/inventory.ts index de5801ae5..83c2011a3 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 +// } + +//// remove? export interface EsmithConfiguration { name: string type: string From 2a15032f5e76a88dfcbbc906ee0ec42350b3786c Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Fri, 6 Mar 2026 18:00:15 +0100 Subject: [PATCH 09/31] Improve system and application icons --- .../src/assets/system_logos/nethserver.svg | 9 ++++--- .../applications/ApplicationInfoCard.vue | 11 +++----- .../applications/ApplicationLogo.vue | 22 ++++++++++++++++ .../applications/ApplicationsTable.vue | 15 +++-------- .../OrganizationApplicationsCard.vue | 10 ++------ .../organizations/OrganizationSystemsCard.vue | 10 ++------ .../systems/SystemApplicationsCard.vue | 10 ++------ .../src/components/systems/SystemInfoCard.vue | 11 +++----- .../src/components/systems/SystemLogo.vue | 22 ++++++++++++++++ .../components/systems/SystemStatusCard.vue | 3 ++- .../src/components/systems/SystemsTable.vue | 25 +++---------------- frontend/src/i18n/en/translation.json | 1 - frontend/src/i18n/it/translation.json | 1 - 13 files changed, 70 insertions(+), 80 deletions(-) create mode 100644 frontend/src/components/applications/ApplicationLogo.vue create mode 100644 frontend/src/components/systems/SystemLogo.vue 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/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..f046e87ad 100644 --- a/frontend/src/components/applications/ApplicationsTable.vue +++ b/frontend/src/components/applications/ApplicationsTable.vue @@ -39,11 +39,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' @@ -377,13 +374,7 @@ const goToApplicationDetails = (application: Application) => {
- + {{ item.name || '-' }} 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..3d53e788a 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,13 +72,7 @@ const goToSystems = () => { class="cursor-pointer font-medium hover:underline" >
- + {{ system.name || '-' }} diff --git a/frontend/src/components/systems/SystemApplicationsCard.vue b/frontend/src/components/systems/SystemApplicationsCard.vue index b71b0cb11..afd3ce5c0 100644 --- a/frontend/src/components/systems/SystemApplicationsCard.vue +++ b/frontend/src/components/systems/SystemApplicationsCard.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/systems/SystemInfoCard.vue b/frontend/src/components/systems/SystemInfoCard.vue index ec001c32c..a5f387c78 100644 --- a/frontend/src/components/systems/SystemInfoCard.vue +++ b/frontend/src/components/systems/SystemInfoCard.vue @@ -15,7 +15,8 @@ import { type NeDropdownItem, } from '@nethesis/vue-components' import { useSystemDetail } from '@/queries/systems/systemDetail' -import { exportSystem, getProductLogo, getProductName } from '@/lib/systems/systems' +import { exportSystem, getProductName } from '@/lib/systems/systems' +import SystemLogo from './SystemLogo.vue' import DataItem from '../DataItem.vue' import ClickToCopy from '../ClickToCopy.vue' import { computed, ref } from 'vue' @@ -158,13 +159,7 @@ function getKebabMenuItems() {
- + {{ getProductName(systemDetail.data.type || '') || $t('system_detail.unknown_product') 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/SystemStatusCard.vue b/frontend/src/components/systems/SystemStatusCard.vue index 748432233..da2b9b7fa 100644 --- a/frontend/src/components/systems/SystemStatusCard.vue +++ b/frontend/src/components/systems/SystemStatusCard.vue @@ -50,7 +50,8 @@ const leaderNode = computed(() => { if (!nodes) { return null } - // eslint-disable-next-line @typescript-eslint/no-explicit-any //// improve typing + //// improve typing + // eslint-disable-next-line @typescript-eslint/no-explicit-any return (Object.values(nodes).find((node: any) => node.cluster_leader === true) as any) ?? null }) diff --git a/frontend/src/components/systems/SystemsTable.vue b/frontend/src/components/systems/SystemsTable.vue index 99a5d672b..b954b3e73 100644 --- a/frontend/src/components/systems/SystemsTable.vue +++ b/frontend/src/components/systems/SystemsTable.vue @@ -45,13 +45,8 @@ import { useI18n } from 'vue-i18n' import { savePageSizeToStorage } from '@/lib/tablePageSize' import { canManageSystems, canDestroySystems } from '@/lib/permissions' import { useSystems } from '@/queries/systems/systems' -import { - exportSystem, - getProductLogo, - getProductName, - SYSTEMS_TABLE_ID, - type System, -} from '@/lib/systems/systems' +import { exportSystem, getProductName, SYSTEMS_TABLE_ID, type System } from '@/lib/systems/systems' +import SystemLogo from './SystemLogo.vue' import router from '@/router' import CreateOrEditSystemDrawer from './CreateOrEditSystemDrawer.vue' import DeleteSystemModal from './DeleteSystemModal.vue' @@ -560,26 +555,14 @@ function onCloseSecretRegeneratedModal() { class="cursor-pointer font-medium hover:underline" >
- + {{ item.name || '-' }}
- + {{ item.name || '-' }} diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 4a6f191df..f6b8484e0 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -501,7 +501,6 @@ "system_detail": { "title": "System detail", "overview": "Overview", - "product_logo": "{product} logo", "unknown_product": "Unknown product", "go_to_system": "Go to system", "go_to_system_tooltip": "Note: The system may be unreachable due to network settings.", diff --git a/frontend/src/i18n/it/translation.json b/frontend/src/i18n/it/translation.json index 83d6e61cf..57d072962 100644 --- a/frontend/src/i18n/it/translation.json +++ b/frontend/src/i18n/it/translation.json @@ -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.", From 6b13108ba3b69336de899f47c4e839c781372ffc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Mar 2026 09:01:19 +0000 Subject: [PATCH 10/31] chore: update build triggers for PR deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-updated .render-build-trigger files to ensure all services are deployed in PR preview environments. 🤖 Generated by GitHub Actions --- backend/.render-build-trigger | 4 ++-- collect/.render-build-trigger | 4 ++-- frontend/.render-build-trigger | 4 ++-- proxy/.render-build-trigger | 4 ++-- services/mimir/.render-build-trigger | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) 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/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 From 5df2b78a5e6fb0db2094acddd727b4c5bc2b5584 Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Mon, 9 Mar 2026 13:00:31 +0100 Subject: [PATCH 11/31] Fix system status --- .../organizations/OrganizationSystemsCard.vue | 9 +++------ .../src/components/systems/SystemStatusCard.vue | 6 +++--- .../src/components/systems/SystemStatusIcon.vue | 17 ++++++++--------- .../src/components/systems/SystemsTable.vue | 17 +++++++---------- frontend/src/i18n/en/translation.json | 3 ++- frontend/src/i18n/it/translation.json | 2 +- frontend/src/lib/applications/applications.ts | 2 +- frontend/src/lib/systems/systems.ts | 12 ++++++------ frontend/src/queries/systems/systems.ts | 8 ++++---- 9 files changed, 35 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/organizations/OrganizationSystemsCard.vue b/frontend/src/components/organizations/OrganizationSystemsCard.vue index 3d53e788a..49cf625ba 100644 --- a/frontend/src/components/organizations/OrganizationSystemsCard.vue +++ b/frontend/src/components/organizations/OrganizationSystemsCard.vue @@ -79,13 +79,10 @@ const goToSystems = () => {
- - - {{ t('common.suspended') }} - - + -
diff --git a/frontend/src/components/systems/SystemStatusCard.vue b/frontend/src/components/systems/SystemStatusCard.vue index da2b9b7fa..58e24fd4e 100644 --- a/frontend/src/components/systems/SystemStatusCard.vue +++ b/frontend/src/components/systems/SystemStatusCard.vue @@ -72,7 +72,7 @@ const timezone = computed(() => { }) const getBadgeKind = () => { - switch (systemDetail.value.data?.heartbeat_status) { + switch (systemDetail.value.data?.status) { case 'active': return 'green' case 'inactive': @@ -85,7 +85,7 @@ const getBadgeKind = () => { } const getBadgeIcon = () => { - switch (systemDetail.value.data?.heartbeat_status) { + switch (systemDetail.value.data?.status) { case 'active': return faCheck case 'inactive': @@ -136,7 +136,7 @@ const getBadgeIcon = () => {
- {{ t(`systems.status_${systemDetail.data?.heartbeat_status}`) }} + {{ t(`systems.status_${systemDetail.data?.status}`) }}
diff --git a/frontend/src/components/systems/SystemStatusIcon.vue b/frontend/src/components/systems/SystemStatusIcon.vue index 7ec7214cd..195ffe9dd 100644 --- a/frontend/src/components/systems/SystemStatusIcon.vue +++ b/frontend/src/components/systems/SystemStatusIcon.vue @@ -15,29 +15,28 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' defineProps<{ status?: string | null - suspendedAt?: string | null }>() diff --git a/frontend/src/components/systems/SystemsTable.vue b/frontend/src/components/systems/SystemsTable.vue index 06bfc70f4..4f8dc3081 100644 --- a/frontend/src/components/systems/SystemsTable.vue +++ b/frontend/src/components/systems/SystemsTable.vue @@ -17,6 +17,7 @@ import { faCirclePause, faCirclePlay, faBomb, + faTriangleExclamation, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { @@ -641,6 +642,23 @@ function onCloseSecretRegeneratedModal() { {{ t(`systems.status_${item.status}`) }} - + + + + +
diff --git a/frontend/src/lib/applications/applications.ts b/frontend/src/lib/applications/applications.ts index af643203d..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' //// needed? - const applicationLogos = import.meta.glob('../../assets/application_logos/*.svg', { eager: true, import: 'default', diff --git a/frontend/src/lib/systems/inventory.ts b/frontend/src/lib/systems/inventory.ts index 83c2011a3..7ef40e1ac 100644 --- a/frontend/src/lib/systems/inventory.ts +++ b/frontend/src/lib/systems/inventory.ts @@ -108,7 +108,7 @@ interface InventoryData { // zone_network_counts: Record // } -//// remove? +//// fix network card, then remove export interface EsmithConfiguration { name: string type: string diff --git a/frontend/src/lib/systems/systems.ts b/frontend/src/lib/systems/systems.ts index d7e6105bb..f4f46acb8 100644 --- a/frontend/src/lib/systems/systems.ts +++ b/frontend/src/lib/systems/systems.ts @@ -13,13 +13,7 @@ export const SYSTEMS_KEY = 'systems' export const SYSTEMS_TOTAL_KEY = 'systemsTotal' export const SYSTEMS_TABLE_ID = 'systemsTable' -export type SystemStatus = 'active' | 'inactive' | 'unknown' | 'deleted' | 'suspended' -const systemStatusOptions = ['active', 'inactive', 'unknown', 'deleted', 'suspended'] -const SystemStatusSchema = v.picklist(systemStatusOptions) - -// export type HeartbeatStatus = 'active' | 'inactive' | 'unknown' //// -// const heartbeatStatusOptions = ['active', 'inactive', 'unknown'] //// -// const HeartbeatStatusSchema = v.picklist(heartbeatStatusOptions) //// +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')), @@ -48,7 +42,8 @@ export const SystemSchema = v.object({ system_key: v.optional(v.string()), system_secret: v.string(), suspended_at: v.optional(v.string()), - // heartbeat_status: HeartbeatStatusSchema, //// + last_inventory: v.optional(v.string()), + rebranding_enabled: v.optional(v.boolean()), organization: v.object({ id: v.string(), logto_id: v.string(), @@ -68,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 From a87dc8823290e2e09ed11c1072558bc0ef50c02a Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Wed, 11 Mar 2026 11:32:48 +0100 Subject: [PATCH 13/31] Update @nethesis/nethesis-light-svg-icons --- frontend/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d82845af..642f58404 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1637,7 +1637,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": { From ba4aeb982df96274ee56d4aca4dd18a4d3a751de Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Wed, 11 Mar 2026 11:45:03 +0100 Subject: [PATCH 14/31] Make phone number optional everywhere --- frontend/src/components/account/ProfilePanel.vue | 2 ++ .../src/components/users/CreateOrEditUserDrawer.vue | 2 ++ frontend/src/lib/account.ts | 5 ++--- frontend/src/lib/users/users.ts | 12 ++++++------ 4 files changed, 12 insertions(+), 9 deletions(-) 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/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')" /> Date: Wed, 11 Mar 2026 15:47:02 +0100 Subject: [PATCH 15/31] Use UpdatingSpinner component --- .../components/applications/ApplicationsTable.vue | 12 ++---------- .../src/components/customers/CustomersTable.vue | 14 +++----------- .../components/distributors/DistributorsTable.vue | 14 +++----------- .../src/components/resellers/ResellersTable.vue | 14 +++----------- frontend/src/components/systems/SystemsTable.vue | 14 +++----------- frontend/src/components/users/UsersTable.vue | 14 +++----------- 6 files changed, 17 insertions(+), 65 deletions(-) diff --git a/frontend/src/components/applications/ApplicationsTable.vue b/frontend/src/components/applications/ApplicationsTable.vue index f046e87ad..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, @@ -48,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 { @@ -309,15 +309,7 @@ const goToApplicationDetails = (application: Application) => {
-
- -
- {{ $t('common.updating') }} -
-
+
diff --git a/frontend/src/components/customers/CustomersTable.vue b/frontend/src/components/customers/CustomersTable.vue index 5370d19a2..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) => { />
-
+
@@ -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 57176bb09..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) => { />
-
+
@@ -311,15 +311,7 @@ const goToDistributorDetails = (distributor: Distributor) => {
-
- -
- {{ $t('common.updating') }} -
-
+
diff --git a/frontend/src/components/resellers/ResellersTable.vue b/frontend/src/components/resellers/ResellersTable.vue index ffe4d565e..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) => { />
-
+
@@ -310,15 +310,7 @@ const goToResellerDetails = (reseller: Reseller) => {
-
- -
- {{ $t('common.updating') }} -
-
+
diff --git a/frontend/src/components/systems/SystemsTable.vue b/frontend/src/components/systems/SystemsTable.vue index 4f8dc3081..09307e81a 100644 --- a/frontend/src/components/systems/SystemsTable.vue +++ b/frontend/src/components/systems/SystemsTable.vue @@ -32,7 +32,6 @@ import { NeEmptyState, NeInlineNotification, NeTextInput, - NeSpinner, NeDropdown, type SortEvent, NeSortDropdown, @@ -63,6 +62,7 @@ import SuspendSystemModal from './SuspendSystemModal.vue' import ReactivateSystemModal from './ReactivateSystemModal.vue' import DestroySystemModal from './DestroySystemModal.vue' import SystemStatusIcon from './SystemStatusIcon.vue' +import UpdatingSpinner from '@/components/UpdatingSpinner.vue' const { isShownCreateSystemDrawer = false } = defineProps<{ isShownCreateSystemDrawer: boolean @@ -384,7 +384,7 @@ function onCloseSecretRegeneratedModal() { />
-
+
@@ -483,15 +483,7 @@ function onCloseSecretRegeneratedModal() {
-
- -
- {{ $t('common.updating') }} -
-
+
diff --git a/frontend/src/components/users/UsersTable.vue b/frontend/src/components/users/UsersTable.vue index 6ea58a32f..17d834e41 100644 --- a/frontend/src/components/users/UsersTable.vue +++ b/frontend/src/components/users/UsersTable.vue @@ -31,7 +31,6 @@ import { NeEmptyState, NeInlineNotification, NeTextInput, - NeSpinner, NeDropdown, type SortEvent, NeSortDropdown, @@ -61,6 +60,7 @@ import UserRoleBadge from './UserRoleBadge.vue' import { useUserFilters } from '@/queries/users/userFilters' import { normalize } from '@/lib/common' import OrganizationLink from '../applications/OrganizationLink.vue' +import UpdatingSpinner from '@/components/UpdatingSpinner.vue' const { isShownCreateUserDrawer = false } = defineProps<{ isShownCreateUserDrawer: boolean @@ -350,7 +350,7 @@ const onClosePasswordChangedModal = () => { />
-
+
@@ -422,15 +422,7 @@ const onClosePasswordChangedModal = () => {
-
- -
- {{ $t('common.updating') }} -
-
+
From 169bb8750ff7a111e496e13b73c8698f77695e38 Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Thu, 12 Mar 2026 12:12:01 +0100 Subject: [PATCH 16/31] Show pretty last inventory time --- .../components/systems/SystemStatusCard.vue | 20 ++- frontend/src/i18n/en/translation.json | 7 +- frontend/src/i18n/it/translation.json | 7 +- frontend/src/lib/dateTime.test.ts | 144 +++++++++++++++++- frontend/src/lib/dateTime.ts | 75 +++++++-- 5 files changed, 232 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/systems/SystemStatusCard.vue b/frontend/src/components/systems/SystemStatusCard.vue index af9b59645..4dc25c13f 100644 --- a/frontend/src/components/systems/SystemStatusCard.vue +++ b/frontend/src/components/systems/SystemStatusCard.vue @@ -5,6 +5,7 @@ + + diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index b6b028857..4e2d42eec 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -504,6 +504,7 @@ "system_detail": { "title": "System detail", "overview": "Overview", + "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.", 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..997516ffa --- /dev/null +++ b/frontend/src/lib/systems/inventoryDiffs.ts @@ -0,0 +1,111 @@ +// 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_id: number + current_id: number + diff_type: InventoryDiffType + field_path: string + previous_value: string + current_value: string + 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, +) => { + 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) + } + + if (toDate.trim()) { + searchParams.append('to_date', toDate) + } + + return searchParams.toString() +} + +export const getInventoryDiffs = ( + systemId: string, + pageNum: number, + pageSize: number, + severity: InventoryDiffSeverity[], + category: InventoryDiffCategory[], + diffType: InventoryDiffType[], + inventoryId: number[], + fromDate: string, + toDate: string, +) => { + const loginStore = useLoginStore() + const queryString = getInventoryDiffsQueryStringParams( + pageNum, + pageSize, + severity, + category, + diffType, + inventoryId, + fromDate, + toDate, + ) + + 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..baee3f7ff --- /dev/null +++ b/frontend/src/lib/systems/inventoryTimeline.ts @@ -0,0 +1,100 @@ +// 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, +) => { + 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) + } + + if (toDate.trim()) { + searchParams.append('to_date', toDate) + } + + return searchParams.toString() +} + +export const getInventoryTimeline = ( + systemId: string, + pageNum: number, + pageSize: number, + severity: InventoryDiffSeverity[], + category: InventoryDiffCategory[], + diffType: InventoryDiffType[], + fromDate: string, + toDate: string, +) => { + const loginStore = useLoginStore() + const queryString = getInventoryTimelineQueryStringParams( + pageNum, + pageSize, + severity, + category, + diffType, + fromDate, + toDate, + ) + + 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/queries/systems/inventoryChanges.ts b/frontend/src/queries/systems/inventoryChanges.ts new file mode 100644 index 000000000..ad90c71bd --- /dev/null +++ b/frontend/src/queries/systems/inventoryChanges.ts @@ -0,0 +1,25 @@ +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import { canReadSystems } from '@/lib/permissions' +import { getInventoryChanges, INVENTORY_CHANGES_KEY } from '@/lib/systems/inventoryChanges' +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: () => getInventoryChanges(route.params.systemId as string), + }) + + 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..baca07e1b --- /dev/null +++ b/frontend/src/queries/systems/inventoryDiffs.ts @@ -0,0 +1,162 @@ +// 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 { canReadSystems } from '@/lib/permissions' +import { DEFAULT_PAGE_SIZE, loadPageSizeFromStorage } from '@/lib/tablePageSize' +import { useLoginStore } from '@/stores/login' +import { defineQuery, useQuery } from '@pinia/colada' +import { computed, ref, watch } from 'vue' +import { useRoute } from 'vue-router' + +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 { 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, + }, + ], + enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId, + query: () => + getInventoryDiffs( + route.params.systemId as string, + pageNum.value, + pageSize.value, + severityFilter.value, + categoryFilter.value, + diffTypeFilter.value, + inventoryIdFilter.value, + fromDate.value, + toDate.value, + ), + }) + + const areDefaultFiltersApplied = computed(() => { + return ( + severityFilter.value.length === 0 && + categoryFilter.value.length === 0 && + diffTypeFilter.value.length === 0 && + inventoryIdFilter.value.length === 0 && + !fromDate.value && + !toDate.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 = '' + } + + return { + ...rest, + state, + asyncStatus, + pageNum, + pageSize, + severityFilter, + categoryFilter, + diffTypeFilter, + inventoryIdFilter, + fromDate, + toDate, + areDefaultFiltersApplied, + resetFilters, + } +}) diff --git a/frontend/src/queries/systems/inventoryTimeline.ts b/frontend/src/queries/systems/inventoryTimeline.ts new file mode 100644 index 000000000..21291f989 --- /dev/null +++ b/frontend/src/queries/systems/inventoryTimeline.ts @@ -0,0 +1,150 @@ +// 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, + INVENTORY_TIMELINE_TABLE_ID, + getInventoryTimeline, +} from '@/lib/systems/inventoryTimeline' +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 { computed, ref, watch } from 'vue' +import { useRoute } from 'vue-router' + +export const useInventoryTimeline = 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 fromDate = ref('') + const toDate = ref('') + + const { state, asyncStatus, ...rest } = useQuery({ + key: () => [ + INVENTORY_TIMELINE_KEY, + { + systemId: route.params.systemId, + pageNum: pageNum.value, + pageSize: pageSize.value, + severityFilter: severityFilter.value, + categoryFilter: categoryFilter.value, + diffTypeFilter: diffTypeFilter.value, + fromDate: fromDate.value, + toDate: toDate.value, + }, + ], + enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId, + query: () => + getInventoryTimeline( + route.params.systemId as string, + pageNum.value, + pageSize.value, + severityFilter.value, + categoryFilter.value, + diffTypeFilter.value, + fromDate.value, + toDate.value, + ), + }) + + const areDefaultFiltersApplied = computed(() => { + return ( + severityFilter.value.length === 0 && + categoryFilter.value.length === 0 && + diffTypeFilter.value.length === 0 && + !fromDate.value && + !toDate.value + ) + }) + + // load table page size from storage + watch( + () => loginStore.userInfo?.email, + (email) => { + if (email) { + pageSize.value = loadPageSizeFromStorage(INVENTORY_TIMELINE_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( + () => fromDate.value, + () => { + pageNum.value = 1 + }, + ) + + watch( + () => toDate.value, + () => { + pageNum.value = 1 + }, + ) + + const resetFilters = () => { + severityFilter.value = [] + categoryFilter.value = [] + diffTypeFilter.value = [] + fromDate.value = '' + toDate.value = '' + } + + return { + ...rest, + state, + asyncStatus, + pageNum, + pageSize, + severityFilter, + categoryFilter, + diffTypeFilter, + fromDate, + toDate, + areDefaultFiltersApplied, + resetFilters, + } +}) 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" /> +
From b9f311a150369561cfcf62fc011c386e80faed1d Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Fri, 20 Mar 2026 09:07:15 +0100 Subject: [PATCH 19/31] Add counter cards in change history --- .../systems/SystemChangeHistoryPanel.vue | 56 ++++++++++++++++++- frontend/src/i18n/en/translation.json | 8 ++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/systems/SystemChangeHistoryPanel.vue b/frontend/src/components/systems/SystemChangeHistoryPanel.vue index 89d3b0cc1..3182c2d81 100644 --- a/frontend/src/components/systems/SystemChangeHistoryPanel.vue +++ b/frontend/src/components/systems/SystemChangeHistoryPanel.vue @@ -3,8 +3,60 @@ SPDX-License-Identifier: GPL-3.0-or-later --> - + diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 4e2d42eec..199ec7322 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -519,10 +519,16 @@ "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", "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.", + "cannot_retrieve_inventory_changes": "Cannot retrieve inventory changes" }, "application_detail": { "title": "Application detail", From 7207c8498b30dfa7aad3df1958dcb55b6a54a9e1 Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Mon, 23 Mar 2026 11:49:49 +0100 Subject: [PATCH 20/31] Implement system changes timeline (wip) --- .../systems/SystemChangeHistoryPanel.vue | 8 +- .../systems/SystemChangesTimeline.vue | 735 ++++++++++++++++++ frontend/src/i18n/en/translation.json | 45 +- frontend/src/lib/systems/inventoryDiffs.ts | 8 +- frontend/src/lib/systems/inventoryMocks.ts | 641 +++++++++++++++ .../src/queries/systems/inventoryTimeline.ts | 98 +-- 6 files changed, 1451 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/systems/SystemChangesTimeline.vue create mode 100644 frontend/src/lib/systems/inventoryMocks.ts diff --git a/frontend/src/components/systems/SystemChangeHistoryPanel.vue b/frontend/src/components/systems/SystemChangeHistoryPanel.vue index 3182c2d81..65a1bd642 100644 --- a/frontend/src/components/systems/SystemChangeHistoryPanel.vue +++ b/frontend/src/components/systems/SystemChangeHistoryPanel.vue @@ -9,6 +9,7 @@ import { useInventoryChanges } from '@/queries/systems/inventoryChanges' import { useI18n } from 'vue-i18n' import UpdatingSpinner from '../UpdatingSpinner.vue' import { NeInlineNotification } from '@nethesis/vue-components' +import SystemChangesTimeline from './SystemChangesTimeline.vue' const { t } = useI18n() @@ -34,7 +35,7 @@ const { state: inventoryChanges, asyncStatus: inventoryChangesAsyncStatus } = us class="mb-6" /> -
+
+ + + diff --git a/frontend/src/components/systems/SystemChangesTimeline.vue b/frontend/src/components/systems/SystemChangesTimeline.vue new file mode 100644 index 000000000..c97572fd1 --- /dev/null +++ b/frontend/src/components/systems/SystemChangesTimeline.vue @@ -0,0 +1,735 @@ + + + + + diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 199ec7322..38fc6c286 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", @@ -528,7 +528,42 @@ "system_key": "System key", "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.", - "cannot_retrieve_inventory_changes": "Cannot retrieve inventory changes" + "cannot_retrieve_inventory_changes": "Cannot retrieve inventory changes", + "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", + "from_date": "From", + "to_date": "To", + "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", diff --git a/frontend/src/lib/systems/inventoryDiffs.ts b/frontend/src/lib/systems/inventoryDiffs.ts index 997516ffa..44a85073a 100644 --- a/frontend/src/lib/systems/inventoryDiffs.ts +++ b/frontend/src/lib/systems/inventoryDiffs.ts @@ -28,12 +28,12 @@ export type InventoryDiffType = 'create' | 'update' | 'delete' export interface InventoryDiff { id: number system_id: string - previous_id: number - current_id: number + previous_inventory_id: number | null + inventory_id: number diff_type: InventoryDiffType field_path: string - previous_value: string - current_value: string + previous_value: unknown + current_value: unknown severity: InventoryDiffSeverity category: InventoryDiffCategory notification_sent: boolean diff --git a/frontend/src/lib/systems/inventoryMocks.ts b/frontend/src/lib/systems/inventoryMocks.ts new file mode 100644 index 000000000..e48bf9367 --- /dev/null +++ b/frontend/src/lib/systems/inventoryMocks.ts @@ -0,0 +1,641 @@ +// Copyright (C) 2026 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +/** + * Mock data for SystemChangesTimeline — pass mockMode prop to the component to + * render with this data instead of live API calls. + * + * Timeline covers today (no changes) + ten historical groups spread across + * four months, with notable gaps between them. Diffs exercise every + * combination of diff_type × severity × category and include scalar, boolean, + * object, array, empty-string, and null values. + */ + +import { type InventoryDiff } from './inventoryDiffs' +import { type InventoryTimelineGroup, type InventoryTimelineSummary } from './inventoryTimeline' +import { type Pagination } from '@/lib/common' + +// ── Reference date ──────────────────────────────────────────────────────────── +// The component always injects today's date dynamically, so these groups start +// from 2026-03-22 (yesterday). + +// ── Timeline summary ────────────────────────────────────────────────────────── +export const mockTimelineSummary: InventoryTimelineSummary = { + total: 35, + critical: 6, + high: 8, + medium: 7, + low: 14, +} + +// ── Timeline groups ─────────────────────────────────────────────────────────── +// Layout (newest → oldest): +// +// 2026-03-22 yesterday 3 changes inventory_ids: 101–103 +// 2026-03-21 2 days ago 1 change inventory_ids: 104 +// 2026-03-18 5 days ago 5 changes inventory_ids: 105–109 (gap: 2 days) +// 2026-03-13 10 days ago 2 changes inventory_ids: 110–111 (gap: 4 days) +// 2026-03-01 22 days ago 4 changes inventory_ids: 112–115 (gap: 11 days) +// 2026-02-15 36 days ago 4 changes inventory_ids: 116–119 (gap: 14 days) +// 2026-02-01 50 days ago 3 changes inventory_ids: 120–122 (gap: 14 days) +// 2026-01-18 64 days ago 5 changes inventory_ids: 123–127 (gap: 14 days) +// 2026-01-05 77 days ago 2 changes inventory_ids: 128–129 (gap: 13 days) +// 2025-12-22 91 days ago 6 changes inventory_ids: 130–135 (gap: 14 days) +export const mockTimelineGroups: InventoryTimelineGroup[] = [ + { + date: '2026-03-22', + inventory_count: 3, + change_count: 3, + inventory_ids: [101, 102, 103], + }, + { + date: '2026-03-21', + inventory_count: 1, + change_count: 1, + inventory_ids: [104], + }, + { + date: '2026-03-18', + inventory_count: 5, + change_count: 5, + inventory_ids: [105, 106, 107, 108, 109], + }, + { + date: '2026-03-13', + inventory_count: 2, + change_count: 2, + inventory_ids: [110, 111], + }, + { + date: '2026-03-01', + inventory_count: 4, + change_count: 4, + inventory_ids: [112, 113, 114, 115], + }, + { + date: '2026-02-15', + inventory_count: 4, + change_count: 4, + inventory_ids: [116, 117, 118, 119], + }, + { + date: '2026-02-01', + inventory_count: 3, + change_count: 3, + inventory_ids: [120, 121, 122], + }, + { + date: '2026-01-18', + inventory_count: 5, + change_count: 5, + inventory_ids: [123, 124, 125, 126, 127], + }, + { + date: '2026-01-05', + inventory_count: 2, + change_count: 2, + inventory_ids: [128, 129], + }, + { + date: '2025-12-22', + inventory_count: 6, + change_count: 6, + inventory_ids: [130, 131, 132, 133, 134, 135], + }, +] + +export const mockTimelinePagination: Pagination = { + page: 1, + page_size: 10, + total_count: 10, + total_pages: 1, + has_next: false, + has_prev: false, +} + +// ── Inventory diffs ─────────────────────────────────────────────────────────── +// 15 diffs covering every diff_type × severity combination and all 10 categories. + +export const mockInventoryDiffs: InventoryDiff[] = [ + // ── 2026-03-22 (inventory_ids 101–103) ────────────────────────────────────── + { + id: 1, + system_id: 'sys-demo-001', + previous_inventory_id: 100, + inventory_id: 101, + diff_type: 'update', + category: 'os', + field_path: 'os.kernel_version', + previous_value: '5.15.0-89-generic', + current_value: '5.15.0-91-generic', + severity: 'low', + notification_sent: false, + created_at: '2026-03-22T08:14:02Z', + }, + { + id: 2, + system_id: 'sys-demo-001', + previous_inventory_id: 100, + inventory_id: 102, + diff_type: 'create', + category: 'security', + field_path: 'security.firewall_rules[12]', + previous_value: null, + current_value: { port: 22, protocol: 'tcp', action: 'ACCEPT', source: '10.0.0.0/8' }, + severity: 'critical', + notification_sent: true, + created_at: '2026-03-22T09:31:45Z', + }, + { + id: 3, + system_id: 'sys-demo-001', + previous_inventory_id: 100, + inventory_id: 103, + diff_type: 'delete', + category: 'network', + field_path: 'network.interfaces.eth1', + previous_value: { ip: '192.168.1.50', netmask: '255.255.255.0', mtu: 1500 }, + current_value: null, + severity: 'medium', + notification_sent: false, + created_at: '2026-03-22T11:05:19Z', + }, + + // ── 2026-03-21 (inventory_id 104) ─────────────────────────────────────────── + { + id: 4, + system_id: 'sys-demo-001', + previous_inventory_id: 103, + inventory_id: 104, + diff_type: 'update', + category: 'hardware', + field_path: 'hardware.cpu.cores', + previous_value: 4, + current_value: 8, + severity: 'high', + notification_sent: true, + created_at: '2026-03-21T14:22:00Z', + }, + + // ── 2026-03-18 (inventory_ids 105–109) ────────────────────────────────────── + { + id: 5, + system_id: 'sys-demo-001', + previous_inventory_id: 104, + inventory_id: 105, + diff_type: 'create', + category: 'modules', + field_path: 'modules[7]', + previous_value: null, + current_value: { name: 'nethserver-fail2ban', version: '1.2.3', active: true }, + severity: 'low', + notification_sent: false, + created_at: '2026-03-18T06:00:01Z', + }, + { + id: 6, + system_id: 'sys-demo-001', + previous_inventory_id: 104, + inventory_id: 106, + diff_type: 'update', + category: 'os', + field_path: 'os.version', + previous_value: 'NethServer 7.9.2009', + current_value: 'NethServer 8.1', + severity: 'high', + notification_sent: true, + created_at: '2026-03-18T06:05:33Z', + }, + { + id: 7, + system_id: 'sys-demo-001', + previous_inventory_id: 104, + inventory_id: 107, + diff_type: 'delete', + category: 'backup', + field_path: 'backup.jobs.nightly_db', + previous_value: { schedule: '0 2 * * *', destination: '/mnt/nas/backup', retention: 7 }, + current_value: null, + severity: 'critical', + notification_sent: true, + created_at: '2026-03-18T06:10:11Z', + }, + { + id: 8, + system_id: 'sys-demo-001', + previous_inventory_id: 104, + inventory_id: 108, + diff_type: 'create', + category: 'cluster', + field_path: 'cluster.nodes[2]', + previous_value: null, + current_value: { name: 'node3', role: 'worker', ip: '10.0.0.3', joined_at: '2026-03-18' }, + severity: 'medium', + notification_sent: false, + created_at: '2026-03-18T07:45:55Z', + }, + { + id: 9, + system_id: 'sys-demo-001', + previous_inventory_id: 104, + inventory_id: 109, + diff_type: 'update', + category: 'features', + field_path: 'features.mail.enabled', + previous_value: false, + current_value: true, + severity: 'low', + notification_sent: false, + created_at: '2026-03-18T09:12:40Z', + }, + + // ── 2026-03-13 (inventory_ids 110–111) ────────────────────────────────────── + { + id: 10, + system_id: 'sys-demo-001', + previous_inventory_id: 109, + inventory_id: 110, + diff_type: 'update', + category: 'system', + field_path: 'system.hostname', + previous_value: 'server01.example.com', + current_value: 'firewall.acme.corp', + severity: 'medium', + notification_sent: false, + created_at: '2026-03-13T16:03:27Z', + }, + { + id: 11, + system_id: 'sys-demo-001', + previous_inventory_id: 109, + inventory_id: 111, + diff_type: 'delete', + category: 'nodes', + field_path: 'nodes[1]', + previous_value: { name: 'worker-2', status: 'active', cpu_cores: 8, ram_gb: 32 }, + current_value: null, + severity: 'high', + notification_sent: true, + created_at: '2026-03-13T17:55:09Z', + }, + + // ── 2026-03-01 (inventory_ids 112–115) ────────────────────────────────────── + { + id: 12, + system_id: 'sys-demo-001', + previous_inventory_id: 111, + inventory_id: 112, + diff_type: 'create', + category: 'network', + field_path: 'network.bonding.bond0', + previous_value: null, + current_value: { mode: 'active-backup', slaves: ['eth0', 'eth1'], mtu: 9000 }, + severity: 'low', + notification_sent: false, + created_at: '2026-03-01T02:00:00Z', + }, + { + id: 13, + system_id: 'sys-demo-001', + previous_inventory_id: 111, + inventory_id: 113, + diff_type: 'update', + category: 'os', + field_path: 'os.packages_update_count', + previous_value: 0, + current_value: 42, + severity: 'high', + notification_sent: true, + created_at: '2026-03-01T02:05:14Z', + }, + { + id: 14, + system_id: 'sys-demo-001', + previous_inventory_id: 111, + inventory_id: 114, + diff_type: 'update', + category: 'security', + field_path: 'security.certificates.web.expiry_days', + previous_value: 90, + current_value: 7, + severity: 'critical', + notification_sent: true, + created_at: '2026-03-01T02:10:30Z', + }, + { + id: 15, + system_id: 'sys-demo-001', + previous_inventory_id: 111, + inventory_id: 115, + diff_type: 'create', + category: 'features', + field_path: 'features.vpn.wireguard', + previous_value: null, + current_value: { enabled: true, port: 51820, peers: 3 }, + severity: 'low', + notification_sent: false, + created_at: '2026-03-01T03:30:00Z', + }, + + // ── 2026-02-15 (inventory_ids 116–119) ────────────────────────────────────── + { + id: 16, + system_id: 'sys-demo-001', + previous_inventory_id: 115, + inventory_id: 116, + diff_type: 'create', + category: 'os', + field_path: 'os.swap_enabled', + previous_value: null, + current_value: false, + severity: 'low', + notification_sent: false, + created_at: '2026-02-15T03:10:00Z', + }, + { + id: 17, + system_id: 'sys-demo-001', + previous_inventory_id: 115, + inventory_id: 117, + diff_type: 'update', + category: 'network', + field_path: 'network.dns_servers', + previous_value: ['1.1.1.1'], + current_value: ['8.8.8.8', '8.8.4.4', '1.1.1.1'], + severity: 'medium', + notification_sent: false, + created_at: '2026-02-15T03:15:22Z', + }, + { + id: 18, + system_id: 'sys-demo-001', + previous_inventory_id: 115, + inventory_id: 118, + diff_type: 'delete', + category: 'security', + field_path: 'security.users.admin.authorized_keys[0]', + previous_value: { key: 'ssh-rsa AAAAB3NzaC1yc2E...', comment: 'old-laptop', bits: 2048 }, + current_value: null, + severity: 'critical', + notification_sent: true, + created_at: '2026-02-15T08:44:11Z', + }, + { + id: 19, + system_id: 'sys-demo-001', + previous_inventory_id: 115, + inventory_id: 119, + diff_type: 'update', + category: 'hardware', + field_path: 'hardware.memory.total_gb', + previous_value: 16, + current_value: 32, + severity: 'low', + notification_sent: false, + created_at: '2026-02-15T10:00:00Z', + }, + + // ── 2026-02-01 (inventory_ids 120–122) ────────────────────────────────────── + { + id: 20, + system_id: 'sys-demo-001', + previous_inventory_id: 119, + inventory_id: 120, + diff_type: 'update', + category: 'os', + field_path: 'os.timezone', + previous_value: 'UTC', + current_value: 'Europe/Rome', + severity: 'low', + notification_sent: false, + created_at: '2026-02-01T09:00:00Z', + }, + { + id: 21, + system_id: 'sys-demo-001', + previous_inventory_id: 119, + inventory_id: 121, + diff_type: 'create', + category: 'modules', + field_path: 'modules[8]', + previous_value: null, + current_value: { name: 'nethserver-mail-server', version: '2.4.1', active: true }, + severity: 'medium', + notification_sent: false, + created_at: '2026-02-01T11:22:33Z', + }, + { + id: 22, + system_id: 'sys-demo-001', + previous_inventory_id: 119, + inventory_id: 122, + diff_type: 'delete', + category: 'cluster', + field_path: 'cluster.virtual_ip', + previous_value: { ip: '10.0.0.100', interface: 'eth0', check_interval_ms: 500 }, + current_value: null, + severity: 'high', + notification_sent: true, + created_at: '2026-02-01T14:55:00Z', + }, + + // ── 2026-01-18 (inventory_ids 123–127) ────────────────────────────────────── + { + id: 23, + system_id: 'sys-demo-001', + previous_inventory_id: 122, + inventory_id: 123, + diff_type: 'update', + category: 'system', + field_path: 'system.fqdn', + previous_value: 'old.server.local', + current_value: 'prod-fw.acme.corp', + severity: 'medium', + notification_sent: false, + created_at: '2026-01-18T07:01:00Z', + }, + { + id: 24, + system_id: 'sys-demo-001', + previous_inventory_id: 122, + inventory_id: 124, + diff_type: 'create', + category: 'network', + field_path: 'network.vlans.vlan10', + previous_value: null, + current_value: { id: 10, name: 'DMZ', interface: 'eth0.10', tagged: true }, + severity: 'low', + notification_sent: false, + created_at: '2026-01-18T07:30:45Z', + }, + { + id: 25, + system_id: 'sys-demo-001', + previous_inventory_id: 122, + inventory_id: 125, + diff_type: 'update', + category: 'os', + field_path: 'os.selinux_status', + previous_value: 'enforcing', + current_value: 'permissive', + severity: 'critical', + notification_sent: true, + created_at: '2026-01-18T09:15:00Z', + }, + { + id: 26, + system_id: 'sys-demo-001', + previous_inventory_id: 122, + inventory_id: 126, + diff_type: 'delete', + category: 'backup', + field_path: 'backup.jobs.weekly_full', + previous_value: { schedule: '0 0 * * 0', destination: 's3://mybucket/weekly', retention: 4 }, + current_value: null, + severity: 'high', + notification_sent: true, + created_at: '2026-01-18T10:00:00Z', + }, + { + id: 27, + system_id: 'sys-demo-001', + previous_inventory_id: 122, + inventory_id: 127, + diff_type: 'update', + category: 'features', + field_path: 'features.antivirus.signatures_date', + previous_value: '2025-01-10', + current_value: '2026-01-18', + severity: 'low', + notification_sent: false, + created_at: '2026-01-18T12:00:00Z', + }, + + // ── 2026-01-05 (inventory_ids 128–129) ────────────────────────────────────── + { + id: 28, + system_id: 'sys-demo-001', + previous_inventory_id: 127, + inventory_id: 128, + diff_type: 'update', + category: 'hardware', + field_path: 'hardware.disk.sda.health', + previous_value: 'OK', + current_value: 'WARN', + severity: 'critical', + notification_sent: true, + created_at: '2026-01-05T04:00:00Z', + }, + { + id: 29, + system_id: 'sys-demo-001', + previous_inventory_id: 127, + inventory_id: 129, + diff_type: 'update', + category: 'nodes', + field_path: 'nodes[0].status', + previous_value: 'active', + current_value: 'maintenance', + severity: 'high', + notification_sent: true, + created_at: '2026-01-05T06:30:00Z', + }, + + // ── 2025-12-22 (inventory_ids 130–135) ────────────────────────────────────── + { + id: 30, + system_id: 'sys-demo-001', + previous_inventory_id: 129, + inventory_id: 130, + diff_type: 'create', + category: 'security', + field_path: 'security.openvpn.certificates[0]', + previous_value: null, + current_value: { cn: 'vpn-user1', expiry: '2027-12-22', bits: 4096 }, + severity: 'low', + notification_sent: false, + created_at: '2025-12-22T01:00:00Z', + }, + { + id: 31, + system_id: 'sys-demo-001', + previous_inventory_id: 129, + inventory_id: 131, + diff_type: 'update', + category: 'os', + field_path: 'os.packages_update_count', + // edge: number going back to zero + previous_value: 23, + current_value: 0, + severity: 'low', + notification_sent: false, + created_at: '2025-12-22T02:00:00Z', + }, + { + id: 32, + system_id: 'sys-demo-001', + previous_inventory_id: 129, + inventory_id: 132, + diff_type: 'delete', + category: 'network', + field_path: 'network.routes.192.168.99.0/24', + previous_value: { gateway: '10.0.0.1', metric: 100, proto: 'static' }, + current_value: null, + severity: 'medium', + notification_sent: false, + created_at: '2025-12-22T03:10:00Z', + }, + { + id: 33, + system_id: 'sys-demo-001', + previous_inventory_id: 129, + inventory_id: 133, + diff_type: 'update', + category: 'system', + field_path: 'system.ssh_port', + previous_value: 22, + current_value: 2222, + severity: 'high', + notification_sent: true, + created_at: '2025-12-22T04:00:00Z', + }, + { + id: 34, + system_id: 'sys-demo-001', + previous_inventory_id: 129, + inventory_id: 134, + diff_type: 'update', + category: 'hardware', + field_path: 'hardware.cpu.model', + // edge: empty string → non-empty (renders as '—' → value) + previous_value: '', + current_value: 'Intel Core i9-13900K @ 3.00GHz', + severity: 'low', + notification_sent: false, + created_at: '2025-12-22T05:00:00Z', + }, + { + id: 35, + system_id: 'sys-demo-001', + // edge: very first inventory snapshot — no previous + previous_inventory_id: null, + inventory_id: 135, + diff_type: 'create', + category: 'os', + field_path: 'os.installation_date', + previous_value: null, + current_value: '2025-12-22', + severity: 'low', + notification_sent: false, + created_at: '2025-12-22T06:00:00Z', + }, +] + +export const mockDiffsPagination: Pagination = { + page: 1, + page_size: 500, + total_count: 35, + total_pages: 1, + has_next: false, + has_prev: false, +} diff --git a/frontend/src/queries/systems/inventoryTimeline.ts b/frontend/src/queries/systems/inventoryTimeline.ts index 21291f989..0590251c8 100644 --- a/frontend/src/queries/systems/inventoryTimeline.ts +++ b/frontend/src/queries/systems/inventoryTimeline.ts @@ -8,34 +8,30 @@ import { } from '@/lib/systems/inventoryDiffs' import { INVENTORY_TIMELINE_KEY, - INVENTORY_TIMELINE_TABLE_ID, getInventoryTimeline, } from '@/lib/systems/inventoryTimeline' 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 { computed, ref, watch } from 'vue' +import { defineQuery, useInfiniteQuery } from '@pinia/colada' +import { computed, ref } from 'vue' import { useRoute } from 'vue-router' +const TIMELINE_PAGE_SIZE = 10 + export const useInventoryTimeline = 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 fromDate = ref('') const toDate = ref('') - const { state, asyncStatus, ...rest } = useQuery({ + const { state, asyncStatus, hasNextPage, loadNextPage } = useInfiniteQuery({ key: () => [ INVENTORY_TIMELINE_KEY, { systemId: route.params.systemId, - pageNum: pageNum.value, - pageSize: pageSize.value, severityFilter: severityFilter.value, categoryFilter: categoryFilter.value, diffTypeFilter: diffTypeFilter.value, @@ -44,19 +40,32 @@ export const useInventoryTimeline = defineQuery(() => { }, ], enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId, - query: () => + initialPageParam: 1, + query: ({ pageParam }) => getInventoryTimeline( route.params.systemId as string, - pageNum.value, - pageSize.value, + pageParam, + TIMELINE_PAGE_SIZE, severityFilter.value, categoryFilter.value, diffTypeFilter.value, fromDate.value, toDate.value, ), + 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), + ) + const areDefaultFiltersApplied = computed(() => { return ( severityFilter.value.length === 0 && @@ -67,64 +76,6 @@ export const useInventoryTimeline = defineQuery(() => { ) }) - // load table page size from storage - watch( - () => loginStore.userInfo?.email, - (email) => { - if (email) { - pageSize.value = loadPageSizeFromStorage(INVENTORY_TIMELINE_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( - () => fromDate.value, - () => { - pageNum.value = 1 - }, - ) - - watch( - () => toDate.value, - () => { - pageNum.value = 1 - }, - ) - const resetFilters = () => { severityFilter.value = [] categoryFilter.value = [] @@ -134,11 +85,10 @@ export const useInventoryTimeline = defineQuery(() => { } return { - ...rest, state, asyncStatus, - pageNum, - pageSize, + hasNextPage, + loadNextPage, severityFilter, categoryFilter, diffTypeFilter, @@ -146,5 +96,7 @@ export const useInventoryTimeline = defineQuery(() => { toDate, areDefaultFiltersApplied, resetFilters, + allInventoryIds, + allGroups, } }) From 552cf4c5b4a86e5563e6ff7b00851f977abd680d Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Mon, 23 Mar 2026 16:36:28 +0100 Subject: [PATCH 21/31] Implement system changes timeline (wip) --- .../systems/SystemChangeHistoryPanel.vue | 3 +- .../systems/SystemChangesTimeline.vue | 67 +++++++++---------- frontend/src/lib/systems/inventoryMocks.ts | 35 +++++++++- .../src/queries/systems/inventoryChanges.ts | 12 +++- .../src/queries/systems/inventoryDiffs.ts | 18 ++++- .../src/queries/systems/inventoryTimeline.ts | 40 ++++++++--- 6 files changed, 121 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/systems/SystemChangeHistoryPanel.vue b/frontend/src/components/systems/SystemChangeHistoryPanel.vue index 65a1bd642..41162dae2 100644 --- a/frontend/src/components/systems/SystemChangeHistoryPanel.vue +++ b/frontend/src/components/systems/SystemChangeHistoryPanel.vue @@ -61,6 +61,5 @@ const { state: inventoryChanges, asyncStatus: inventoryChangesAsyncStatus } = us />
- - + diff --git a/frontend/src/components/systems/SystemChangesTimeline.vue b/frontend/src/components/systems/SystemChangesTimeline.vue index c97572fd1..452d7ca23 100644 --- a/frontend/src/components/systems/SystemChangesTimeline.vue +++ b/frontend/src/components/systems/SystemChangesTimeline.vue @@ -13,7 +13,11 @@ import { type InventoryDiffSeverity, type InventoryDiffType, } from '@/lib/systems/inventoryDiffs' -import { mockInventoryDiffs, mockTimelineGroups } from '@/lib/systems/inventoryMocks' +import { + INVENTORY_MOCK_ENABLED, + mockInventoryDiffs, + mockDiffsPagination, +} from '@/lib/systems/inventoryMocks' import { formatDateTimeNoSeconds } from '@/lib/dateTime' import { canReadSystems } from '@/lib/permissions' import { useLoginStore } from '@/stores/login' @@ -42,9 +46,6 @@ import { faArrowRight, } from '@fortawesome/free-solid-svg-icons' -//// remove mock-mode -const props = withDefaults(defineProps<{ mockMode?: boolean }>(), { mockMode: false }) - const { t, locale } = useI18n() const route = useRoute() const loginStore = useLoginStore() @@ -87,13 +88,12 @@ const { state: diffsState, asyncStatus: diffsAsyncStatus } = useQuery({ }, ], enabled: () => - !props.mockMode && !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId && allInventoryIds.value.length > 0, - query: () => - getInventoryDiffs( + query: () => { + const apiCall = getInventoryDiffs( route.params.systemId as string, 1, 500, @@ -103,16 +103,18 @@ const { state: diffsState, asyncStatus: diffsAsyncStatus } = useQuery({ allInventoryIds.value, fromDate.value, toDate.value, - ), + ) + if (INVENTORY_MOCK_ENABLED) { + apiCall.catch(() => {}) + return Promise.resolve({ diffs: mockInventoryDiffs, pagination: mockDiffsPagination }) + } + return apiCall + }, }) -// ── Mock mode overrides ─────────────────────────────────────────────────────── -const effectiveGroups = computed(() => (props.mockMode ? mockTimelineGroups : allGroups.value)) - -// ── Auto-expand all groups when they load ──────────────────────────────────── +// ── Auto-expand all groups when they load ───────────────────────────────────── watch( - //// revert to allGroups.value when mockMode is removed - () => effectiveGroups.value, + () => allGroups.value, (groups) => { groups.forEach((g) => expandedGroups.value.add(g.date)) }, @@ -183,7 +185,7 @@ interface DisplayGroup { const today = todayDateString() const displayGroups = computed(() => { - const groups = effectiveGroups.value + const groups = allGroups.value const result: DisplayGroup[] = [] // Always show Today as the first entry @@ -218,31 +220,23 @@ const displayGroups = computed(() => { }) const isTimelineEmpty = computed(() => { - if (!props.mockMode && timelineState.value.status !== 'success') return false - return effectiveGroups.value.filter((g) => g.change_count > 0).length === 0 + if (timelineState.value.status !== 'success') return false + return allGroups.value.filter((g) => g.change_count > 0).length === 0 }) // ── Diffs helpers ───────────────────────────────────────────────────────────── -const allDiffs = computed(() => - props.mockMode ? mockInventoryDiffs : (diffsState.value.data?.diffs ?? []), -) +const allDiffs = computed(() => diffsState.value.data?.diffs ?? []) -const timelineIsPending = computed( - () => !props.mockMode && timelineState.value.status === 'pending', -) +const timelineIsPending = computed(() => timelineState.value.status === 'pending') const timelineError = computed(() => - !props.mockMode && timelineState.value.status === 'error' ? timelineState.value.error : null, + timelineState.value.status === 'error' ? timelineState.value.error : null, ) const diffsError = computed(() => - !props.mockMode && diffsState.value.status === 'error' ? diffsState.value.error : null, + diffsState.value.status === 'error' ? diffsState.value.error : null, ) const diffsIsLoading = computed( - () => - !props.mockMode && - (diffsState.value.status === 'pending' || diffsAsyncStatus.value === 'loading'), + () => diffsState.value.status === 'pending' || diffsAsyncStatus.value === 'loading', ) -const effectiveHasNextPage = computed(() => !props.mockMode && hasNextPage.value) - function getDiffsForGroup(group: DisplayGroup): InventoryDiff[] { const idSet = new Set(group.inventory_ids) return allDiffs.value.filter((d) => idSet.has(d.inventory_id)) @@ -346,11 +340,11 @@ watch(loadMoreTrigger, (el) => { if (!el) return const observer = new IntersectionObserver( (entries) => { - if (entries[0]?.isIntersecting && hasNextPage.value) { + if (entries[0]?.isIntersecting) { loadNextPage() } }, - { rootMargin: '200px', threshold: [0] }, + { rootMargin: '300px', threshold: [0] }, ) observer.observe(el) onWatcherCleanup(() => observer.disconnect()) @@ -710,7 +704,7 @@ const diffTypeFilterModel = computed({
-
+
({
-
+
-

+

{{ t('common.loading') }}

diff --git a/frontend/src/lib/systems/inventoryMocks.ts b/frontend/src/lib/systems/inventoryMocks.ts index e48bf9367..04dc9ffd5 100644 --- a/frontend/src/lib/systems/inventoryMocks.ts +++ b/frontend/src/lib/systems/inventoryMocks.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later /** - * Mock data for SystemChangesTimeline — pass mockMode prop to the component to - * render with this data instead of live API calls. + * Mock data for inventory queries — the Pinia Colada queries fire the real API + * calls but resolve with this data instead of the actual server response. * * Timeline covers today (no changes) + ten historical groups spread across * four months, with notable gaps between them. Diffs exercise every @@ -12,9 +12,12 @@ */ import { type InventoryDiff } from './inventoryDiffs' +import { type InventoryChanges } from './inventoryChanges' import { type InventoryTimelineGroup, type InventoryTimelineSummary } from './inventoryTimeline' import { type Pagination } from '@/lib/common' +export const INVENTORY_MOCK_ENABLED = false + // ── Reference date ──────────────────────────────────────────────────────────── // The component always injects today's date dynamically, so these groups start // from 2026-03-22 (yesterday). @@ -639,3 +642,31 @@ export const mockDiffsPagination: Pagination = { has_next: false, has_prev: false, } + +// ── Inventory changes summary ───────────────────────────────────────────────── +export const mockInventoryChanges: InventoryChanges = { + system_id: 'sys-demo-001', + total_changes: 35, + recent_changes: 4, + last_inventory_time: '2026-03-22T11:05:19Z', + has_critical_changes: true, + has_alerts: true, + changes_by_category: { + os: 9, + security: 6, + network: 5, + hardware: 5, + modules: 3, + features: 2, + backup: 2, + cluster: 1, + nodes: 1, + system: 1, + }, + changes_by_severity: { + critical: 6, + high: 8, + medium: 7, + low: 14, + }, +} diff --git a/frontend/src/queries/systems/inventoryChanges.ts b/frontend/src/queries/systems/inventoryChanges.ts index ad90c71bd..7c5b62092 100644 --- a/frontend/src/queries/systems/inventoryChanges.ts +++ b/frontend/src/queries/systems/inventoryChanges.ts @@ -1,8 +1,9 @@ // Copyright (C) 2026 Nethesis S.r.l. // SPDX-License-Identifier: GPL-3.0-or-later -import { canReadSystems } from '@/lib/permissions' import { getInventoryChanges, INVENTORY_CHANGES_KEY } from '@/lib/systems/inventoryChanges' +import { INVENTORY_MOCK_ENABLED, mockInventoryChanges } from '@/lib/systems/inventoryMocks' +import { canReadSystems } from '@/lib/permissions' import { useLoginStore } from '@/stores/login' import { defineQuery, useQuery } from '@pinia/colada' import { useRoute } from 'vue-router' @@ -14,7 +15,14 @@ export const useInventoryChanges = defineQuery(() => { const { state, asyncStatus, ...rest } = useQuery({ key: () => [INVENTORY_CHANGES_KEY, route.params.systemId], enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId, - query: () => getInventoryChanges(route.params.systemId as string), + query: () => { + const apiCall = getInventoryChanges(route.params.systemId as string) + if (INVENTORY_MOCK_ENABLED) { + apiCall.catch(() => {}) + return Promise.resolve(mockInventoryChanges) + } + return apiCall + }, }) return { diff --git a/frontend/src/queries/systems/inventoryDiffs.ts b/frontend/src/queries/systems/inventoryDiffs.ts index baca07e1b..8c45b9924 100644 --- a/frontend/src/queries/systems/inventoryDiffs.ts +++ b/frontend/src/queries/systems/inventoryDiffs.ts @@ -9,6 +9,11 @@ import { type InventoryDiffSeverity, type InventoryDiffType, } from '@/lib/systems/inventoryDiffs' +import { + INVENTORY_MOCK_ENABLED, + mockInventoryDiffs, + mockDiffsPagination, +} from '@/lib/systems/inventoryMocks' import { canReadSystems } from '@/lib/permissions' import { DEFAULT_PAGE_SIZE, loadPageSizeFromStorage } from '@/lib/tablePageSize' import { useLoginStore } from '@/stores/login' @@ -16,6 +21,7 @@ import { defineQuery, useQuery } from '@pinia/colada' import { computed, ref, watch } from 'vue' import { useRoute } from 'vue-router' +//// currently unused? export const useInventoryDiffs = defineQuery(() => { const loginStore = useLoginStore() const route = useRoute() @@ -44,8 +50,8 @@ export const useInventoryDiffs = defineQuery(() => { }, ], enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId, - query: () => - getInventoryDiffs( + query: () => { + const apiCall = getInventoryDiffs( route.params.systemId as string, pageNum.value, pageSize.value, @@ -55,7 +61,13 @@ export const useInventoryDiffs = defineQuery(() => { inventoryIdFilter.value, fromDate.value, toDate.value, - ), + ) + if (INVENTORY_MOCK_ENABLED) { + apiCall.catch(() => {}) + return Promise.resolve({ diffs: mockInventoryDiffs, pagination: mockDiffsPagination }) + } + return apiCall + }, }) const areDefaultFiltersApplied = computed(() => { diff --git a/frontend/src/queries/systems/inventoryTimeline.ts b/frontend/src/queries/systems/inventoryTimeline.ts index 0590251c8..c9942e973 100644 --- a/frontend/src/queries/systems/inventoryTimeline.ts +++ b/frontend/src/queries/systems/inventoryTimeline.ts @@ -6,17 +6,19 @@ import { type InventoryDiffSeverity, type InventoryDiffType, } from '@/lib/systems/inventoryDiffs' +import { INVENTORY_TIMELINE_KEY, getInventoryTimeline } from '@/lib/systems/inventoryTimeline' import { - INVENTORY_TIMELINE_KEY, - getInventoryTimeline, -} from '@/lib/systems/inventoryTimeline' + INVENTORY_MOCK_ENABLED, + mockTimelineSummary, + mockTimelineGroups, +} from '@/lib/systems/inventoryMocks' import { canReadSystems } from '@/lib/permissions' import { useLoginStore } from '@/stores/login' import { defineQuery, useInfiniteQuery } from '@pinia/colada' import { computed, ref } from 'vue' import { useRoute } from 'vue-router' -const TIMELINE_PAGE_SIZE = 10 +const TIMELINE_PAGE_SIZE = 5 //// 20 export const useInventoryTimeline = defineQuery(() => { const loginStore = useLoginStore() @@ -41,8 +43,8 @@ export const useInventoryTimeline = defineQuery(() => { ], enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId, initialPageParam: 1, - query: ({ pageParam }) => - getInventoryTimeline( + query: ({ pageParam }) => { + const apiCall = getInventoryTimeline( route.params.systemId as string, pageParam, TIMELINE_PAGE_SIZE, @@ -51,7 +53,27 @@ export const useInventoryTimeline = defineQuery(() => { diffTypeFilter.value, fromDate.value, toDate.value, - ), + ) + if (INVENTORY_MOCK_ENABLED) { + apiCall.catch(() => {}) + const start = (pageParam - 1) * TIMELINE_PAGE_SIZE + const pagedGroups = mockTimelineGroups.slice(start, start + TIMELINE_PAGE_SIZE) + const totalPages = Math.ceil(mockTimelineGroups.length / TIMELINE_PAGE_SIZE) + return Promise.resolve({ + summary: mockTimelineSummary, + groups: pagedGroups, + pagination: { + page: pageParam, + page_size: TIMELINE_PAGE_SIZE, + total_count: mockTimelineGroups.length, + total_pages: totalPages, + has_next: pageParam < totalPages, + has_prev: pageParam > 1, + }, + }) + } + return apiCall + }, getNextPageParam: (lastPage) => lastPage.pagination.has_next ? lastPage.pagination.page + 1 : null, }) @@ -62,9 +84,7 @@ export const useInventoryTimeline = defineQuery(() => { ), ) - const allGroups = computed(() => - (state.value.data?.pages ?? []).flatMap((page) => page.groups), - ) + const allGroups = computed(() => (state.value.data?.pages ?? []).flatMap((page) => page.groups)) const areDefaultFiltersApplied = computed(() => { return ( From 493cbcf1b96cccab70b56b3a37306e6e4866ca87 Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Tue, 24 Mar 2026 09:58:37 +0100 Subject: [PATCH 22/31] Implement system changes timeline (wip) --- .../systems/SystemChangesTimeline.vue | 150 ++++++++++++------ 1 file changed, 103 insertions(+), 47 deletions(-) diff --git a/frontend/src/components/systems/SystemChangesTimeline.vue b/frontend/src/components/systems/SystemChangesTimeline.vue index 452d7ca23..941a9836c 100644 --- a/frontend/src/components/systems/SystemChangesTimeline.vue +++ b/frontend/src/components/systems/SystemChangesTimeline.vue @@ -33,6 +33,7 @@ import { NeSkeleton, NeTextInput, type FilterOption, + NeSpinner, } from '@nethesis/vue-components' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { @@ -187,31 +188,51 @@ const today = todayDateString() const displayGroups = computed(() => { const groups = allGroups.value const result: DisplayGroup[] = [] + const search = textFilter.value.trim().toLowerCase() - // Always show Today as the first entry const todayGroup = groups.find((g) => g.date === today) - const firstRealGroup = todayGroup ? groups[1] : groups[0] - - const gapAfterToday = firstRealGroup ? gapDaysBetween(today, firstRealGroup.date, !todayGroup) : 0 + const otherGroups = todayGroup ? groups.slice(1) : groups - result.push({ - date: today, - isToday: true, - change_count: todayGroup?.change_count ?? 0, - inventory_ids: todayGroup?.inventory_ids ?? [], - gapDaysAfter: gapAfterToday > 0 ? gapAfterToday : 0, + // Build a flat ordered list: today first, then everything else + const allOrderedEntries = [ + { + date: today, + isToday: true, + change_count: todayGroup?.change_count ?? 0, + inventory_ids: todayGroup?.inventory_ids ?? [], + }, + ...otherGroups.map((g) => ({ + date: g.date, + isToday: false, + change_count: g.change_count, + inventory_ids: g.inventory_ids, + })), + ] + + // Skip non-today groups with change_count === 0 (e.g. when a filter hides all + // their diffs). Their date range gets absorbed into the gap of the preceding + // visible entry, so only a single badge is shown between two real changes. + // When a text filter is active and diffs are loaded, also skip groups whose + // diffs are all filtered out by the text search. + const visibleEntries = allOrderedEntries.filter((e) => { + if (e.isToday) return true + if (e.change_count === 0) return false + if (search && diffsHaveEverLoaded.value) { + const idSet = new Set(e.inventory_ids) + return allDiffs.value.some( + (d) => idSet.has(d.inventory_id) && d.field_path.toLowerCase().includes(search), + ) + } + return true }) - // Add the remaining groups (skip today's group if it was in allGroups) - const otherGroups = todayGroup ? groups.slice(1) : groups - otherGroups.forEach((group, idx) => { - const nextGroup = otherGroups[idx + 1] - const gapAfter = nextGroup ? gapDaysBetween(group.date, nextGroup.date, false) : 0 + visibleEntries.forEach((entry, idx) => { + const nextEntry = visibleEntries[idx + 1] + // newerIsToday=true means "today itself is a gap day" (no inventory collected today) + const newerIsToday = entry.isToday && entry.change_count === 0 + const gapAfter = nextEntry ? gapDaysBetween(entry.date, nextEntry.date, newerIsToday) : 0 result.push({ - date: group.date, - isToday: false, - change_count: group.change_count, - inventory_ids: group.inventory_ids, + ...entry, gapDaysAfter: gapAfter > 0 ? gapAfter : 0, }) }) @@ -225,9 +246,30 @@ const isTimelineEmpty = computed(() => { }) // ── Diffs helpers ───────────────────────────────────────────────────────────── -const allDiffs = computed(() => diffsState.value.data?.diffs ?? []) -const timelineIsPending = computed(() => timelineState.value.status === 'pending') +// Stable snapshot of diffs from the last completed fetch — never clears during refetch +// so existing groups keep showing their diffs while a new page is loading. +const stableDiffs = ref([]) +const lastFetchedInventoryIds = ref>(new Set()) +const diffsHaveEverLoaded = ref(false) + +watch( + () => diffsState.value.data, + (data) => { + if (data !== undefined) { + stableDiffs.value = data.diffs + lastFetchedInventoryIds.value = new Set(allInventoryIds.value) + diffsHaveEverLoaded.value = true + } + }, +) + +// allDiffs always returns stable data — never empty during a refetch +const allDiffs = computed(() => stableDiffs.value) + +const timelineIsPending = computed( + () => timelineState.value.status === 'pending' || diffsIsLoading.value, +) const timelineError = computed(() => timelineState.value.status === 'error' ? timelineState.value.error : null, ) @@ -235,13 +277,25 @@ const diffsError = computed(() => diffsState.value.status === 'error' ? diffsState.value.error : null, ) const diffsIsLoading = computed( - () => diffsState.value.status === 'pending' || diffsAsyncStatus.value === 'loading', + () => !diffsHaveEverLoaded.value && diffsState.value.status === 'pending', +) +// True while allInventoryIds has IDs not yet covered by the last completed diffs fetch +const diffsIsRefetching = computed( + () => + diffsHaveEverLoaded.value && + allInventoryIds.value.some((id) => !lastFetchedInventoryIds.value.has(id)), ) function getDiffsForGroup(group: DisplayGroup): InventoryDiff[] { const idSet = new Set(group.inventory_ids) return allDiffs.value.filter((d) => idSet.has(d.inventory_id)) } +// Returns true when this group's diffs haven't been fetched yet (new page, still loading) +function isGroupPendingDiffs(group: DisplayGroup): boolean { + if (!diffsIsRefetching.value) return false + return group.inventory_ids.some((id) => !lastFetchedInventoryIds.value.has(id)) +} + function formatDiffValue(value: unknown): string { if (value === null || value === undefined) return '—' if (typeof value === 'object') return JSON.stringify(value) @@ -467,6 +521,12 @@ const diffTypeFilterModel = computed({ class="mb-6" /> + + +
@@ -512,7 +572,7 @@ const diffTypeFilterModel = computed({
-
+
({ -
+