-
+
@@ -394,7 +394,7 @@ const onClosePasswordChangedModal = () => {
:label="t('common.status')"
:options="statusFilterOptions"
:show-clear-filter="false"
- :clear-filter-label="t('ne_dropdown_filter.reset_filter')"
+ :clear-filter-label="t('ne_dropdown_filter.clear_filter')"
:open-menu-aria-label="t('ne_dropdown_filter.open_filter')"
:no-options-label="t('ne_dropdown_filter.no_options')"
:more-options-hidden-label="t('ne_dropdown_filter.more_options_hidden')"
@@ -422,15 +422,7 @@ const onClosePasswordChangedModal = () => {
-
-
-
- {{ $t('common.updating') }}
-
-
+
diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json
index f8b0efae8..68da15ecd 100644
--- a/frontend/src/i18n/en/translation.json
+++ b/frontend/src/i18n/en/translation.json
@@ -30,10 +30,10 @@
"go_to_page": "Go to {page}",
"open_page": "Open {page}",
"actions": "Actions",
- "object_created_successfully": "{name} created successfully",
- "object_saved_successfully": "{name} saved successfully",
- "object_destroyed_successfully": "{name} destroyed successfully",
- "object_archived_successfully": "{name} archived successfully",
+ "object_created_successfully": "{name} has been created successfully",
+ "object_saved_successfully": "{name} has been updated successfully",
+ "object_destroyed_successfully": "{name} has been destroyed successfully",
+ "object_archived_successfully": "{name} has been archived successfully",
"updating": "Updating...",
"loading": "Loading...",
"show": "Show",
@@ -428,7 +428,9 @@
"filter_systems": "Filter systems",
"product": "Product",
"version": "Version",
+ "created": "Created",
"created_by": "Created by",
+ "created_by_name": "Created by {name}",
"organization": "Company",
"organization_helper": "Choose the company this system will be assigned to",
"status": "Status",
@@ -456,9 +458,10 @@
"complete_the_subscription": "Complete the subscription",
"system_secret_warning": "Copy the secret and paste it into the Subscription page of the system. You won't be able to see the secret again.",
"copy_system_secret": "Copy system secret",
- "status_online": "Active",
- "status_offline": "Inactive",
- "status_unknown": "Inventory not received",
+ "status_active": "Active",
+ "status_inactive": "Inactive",
+ "status_unknown": "Pending",
+ "status_suspended": "Suspended",
"status_deleted": "Archived",
"reset_filters": "Reset filters",
"copy_and_close": "Copy and close",
@@ -501,23 +504,64 @@
"system_detail": {
"title": "System detail",
"overview": "Overview",
- "product_logo": "{product} logo",
+ "change_history": "Change history",
"unknown_product": "Unknown product",
"go_to_system": "Go to system",
"go_to_system_tooltip": "Note: The system may be unreachable due to network settings.",
"uptime": "Uptime",
+ "leader_node_uptime": "Leader node uptime",
"last_inventory": "Last inventory",
"timezone": "Timezone",
+ "leader_node_timezone": "Leader node timezone",
"network": "Network",
"dns_servers": "DNS servers",
"cannot_retrieve_system_detail": "Cannot retrieve system detail",
"cannot_retrieve_latest_inventory": "Cannot retrieve latest inventory",
"no_inventory_available": "No inventory available",
"no_inventory_available_description": "The system has not sent any inventory data yet.",
+ "total_changes": "Total changes",
+ "critical_changes": "Critical",
+ "high_changes": "High",
+ "medium_changes": "Medium",
"subscription": "Subscription",
- "system_creation": "System creation",
+ "active_since": "Active since",
"system_key": "System key",
- "cannot_determine_system_url_description": "Cannot access the system because its URL cannot be determined."
+ "cannot_determine_system_url_description": "Cannot access the system because its URL cannot be determined.",
+ "change_history_description": "The change history is based on the differences between the inventories sent by the system. If the system has not sent any inventory yet, no change history will be available.",
+ "today": "Today",
+ "no_changes_today": "No changes",
+ "one_day_no_changes": "1 day, no changes",
+ "n_days_no_changes": "{n} days, no changes",
+ "one_change": "1 change",
+ "n_changes": "{n} changes",
+ "severity": "Severity",
+ "category": "Category",
+ "change_type": "Change type",
+ "date_range": "Date range",
+ "cannot_retrieve_inventory_timeline": "Cannot retrieve inventory timeline",
+ "cannot_retrieve_inventory_diffs": "Cannot retrieve inventory diffs",
+ "previous_value": "Previous value",
+ "current_value": "Current value",
+ "diff_type_create": "Created",
+ "diff_type_update": "Updated",
+ "diff_type_delete": "Deleted",
+ "severity_critical": "Critical",
+ "severity_high": "High",
+ "severity_medium": "Medium",
+ "severity_low": "Low",
+ "category_os": "OS",
+ "category_hardware": "Hardware",
+ "category_network": "Network",
+ "category_security": "Security",
+ "category_backup": "Backup",
+ "category_features": "Features",
+ "category_modules": "Modules",
+ "category_cluster": "Cluster",
+ "category_nodes": "Nodes",
+ "category_system": "System",
+ "no_changes_in_timeline": "No change history",
+ "no_changes_in_timeline_description": "No changes match the current filters.",
+ "no_changes_in_timeline_no_filters_description": "The system has not reported any changes yet."
},
"application_detail": {
"title": "Application detail",
@@ -603,7 +647,12 @@
"seconds": "{count} second | {count} seconds",
"minutes": "{count} minute | {count} minutes",
"hours": "{count} hour | {count} hours",
- "days": "{count} day | {count} days"
+ "days": "{count} day | {count} days",
+ "weeks": "{count} week | {count} weeks",
+ "months": "{count} month | {count} months",
+ "years": "{count} year | {count} years",
+ "just_now": "Just now",
+ "ago": "{time} ago"
},
"delete_object_modal": {
"type_to_confirm": "Type '{confirmationText}' to confirm",
diff --git a/frontend/src/i18n/it/translation.json b/frontend/src/i18n/it/translation.json
index b0915ca4a..6b9244fbe 100644
--- a/frontend/src/i18n/it/translation.json
+++ b/frontend/src/i18n/it/translation.json
@@ -456,9 +456,9 @@
"complete_the_subscription": "Completa la subscription",
"system_secret_warning": "Copia il segreto e incollalo nella pagina Subscription del sistema. Non sarai più in grado di visualizzare il segreto.",
"copy_system_secret": "Copia segreto del sistema",
- "status_online": "Attivo",
- "status_offline": "Inattivo",
- "status_unknown": "Inventario non ricevuto",
+ "status_active": "Attivo",
+ "status_inactive": "Inattivo",
+ "status_unknown": "In attesa",
"status_deleted": "Archiviato",
"reset_filters": "Reimposta filtri",
"copy_and_close": "Copia e chiudi",
@@ -501,7 +501,6 @@
"system_detail": {
"title": "Dettaglio sistema",
"overview": "Panoramica",
- "product_logo": "Logo di {product}",
"unknown_product": "Prodotto sconosciuto",
"go_to_system": "Vai al sistema",
"go_to_system_tooltip": "Nota: Il sistema potrebbe non essere raggiungibile a causa delle impostazioni di rete.",
@@ -515,7 +514,7 @@
"no_inventory_available": "Nessun inventario disponibile",
"no_inventory_available_description": "Il sistema non ha ancora inviato dati di inventario.",
"subscription": "Subscription",
- "system_creation": "Creazione sistema",
+ "active_since": "Attiva dal",
"system_key": "Chiave di sistema",
"cannot_determine_system_url_description": "Impossibile accedere al sistema perché non è possibile determinarne l'URL."
},
@@ -603,7 +602,12 @@
"seconds": "{count} secondo | {count} secondi",
"minutes": "{count} minuto | {count} minuti",
"hours": "{count} ora | {count} ore",
- "days": "{count} giorno | {count} giorni"
+ "days": "{count} giorno | {count} giorni",
+ "weeks": "{count} settimana | {count} settimane",
+ "months": "{count} mese | {count} mesi",
+ "years": "{count} anno | {count} anni",
+ "just_now": "Poco fa",
+ "ago": "{time} fa"
},
"delete_object_modal": {
"type_to_confirm": "Digita '{confirmationText}' per confermare",
diff --git a/frontend/src/lib/account.ts b/frontend/src/lib/account.ts
index 9d799d793..5641dad01 100644
--- a/frontend/src/lib/account.ts
+++ b/frontend/src/lib/account.ts
@@ -5,13 +5,12 @@ import axios from 'axios'
import { API_URL } from './config'
import { useLoginStore } from '@/stores/login'
import * as v from 'valibot'
+import { PhoneNumberSchema } from './users/users'
export const ProfileInfoSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty('users.name_cannot_be_empty')),
email: v.pipe(v.string(), v.nonEmpty('users.email_required'), v.email('users.email_invalid')),
- phone: v.optional(
- v.pipe(v.string(), v.regex(/^\+?[\d\s\-\(\)]{7,20}$/, 'users.phone_invalid_format')),
- ),
+ phone: v.optional(v.union([v.literal(''), PhoneNumberSchema])),
})
export const ChangePasswordSchema = v.pipe(
diff --git a/frontend/src/lib/applications/applications.ts b/frontend/src/lib/applications/applications.ts
index b5e6de4ba..907fc4347 100644
--- a/frontend/src/lib/applications/applications.ts
+++ b/frontend/src/lib/applications/applications.ts
@@ -14,8 +14,6 @@ export const APPLICATIONS_TOTAL_KEY = 'applicationsTotal'
export const APPLICATIONS_TABLE_ID = 'applicationsTable'
export const SHOW_UNASSIGNED_APPS_NOTIFICATION = 'showUnassignedAppsNotification'
-export type ApplicationStatus = 'online' | 'offline' | 'unknown' | 'deleted'
-
const applicationLogos = import.meta.glob('../../assets/application_logos/*.svg', {
eager: true,
import: 'default',
diff --git a/frontend/src/lib/dateTime.test.ts b/frontend/src/lib/dateTime.test.ts
index e35d07804..c0eec4a3e 100644
--- a/frontend/src/lib/dateTime.test.ts
+++ b/frontend/src/lib/dateTime.test.ts
@@ -6,21 +6,42 @@ import {
formatDateTimeNoSeconds,
formatMinutes,
formatSeconds,
+ formatTimeAgo,
formatUptime,
} from './dateTime'
-import { expect, it, describe, vi, beforeEach } from 'vitest'
+import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest'
// Create a simple mock function for translation
-const mockT = vi.fn((key: string, count: number) => {
+const mockT = vi.fn((key: string, countOrNamed?: number | Record
) => {
const translations: Record string> = {
'time.seconds': (count: number) => `${count} second${count !== 1 ? 's' : ''}`,
'time.minutes': (count: number) => `${count} minute${count !== 1 ? 's' : ''}`,
'time.hours': (count: number) => `${count} hour${count !== 1 ? 's' : ''}`,
'time.days': (count: number) => `${count} day${count !== 1 ? 's' : ''}`,
+ 'time.weeks': (count: number) => `${count} week${count !== 1 ? 's' : ''}`,
+ 'time.months': (count: number) => `${count} month${count !== 1 ? 's' : ''}`,
+ 'time.years': (count: number) => `${count} year${count !== 1 ? 's' : ''}`,
}
- if (translations[key]) {
- return translations[key](count)
+ // Handle named parameter form: t('time.ago', { time: '...' })
+ if (typeof countOrNamed === 'object' && countOrNamed !== null) {
+ if (key === 'time.ago') {
+ return `${countOrNamed.time} ago`
+ }
+ return key
+ }
+
+ // Handle pluralization form: t('time.minutes', count)
+ if (typeof countOrNamed === 'number' && translations[key]) {
+ return translations[key](countOrNamed)
+ }
+
+ // Handle simple keys
+ const simpleKeys: Record = {
+ 'time.just_now': 'Just now',
+ }
+ if (simpleKeys[key]) {
+ return simpleKeys[key]
}
return key
@@ -67,7 +88,7 @@ describe('formatDateTimeNoSeconds', () => {
expect(result).not.toContain('45')
// Should contain year, month, day, hour, minute
expect(result).toContain('2025')
- expect(result).toContain('10')
+ expect(result).toContain('Oct')
expect(result).toContain('03')
expect(result).toContain('09')
expect(result).toContain('30')
@@ -90,7 +111,7 @@ describe('formatDateTimeNoSeconds', () => {
expect(typeof result).toBe('string')
expect(result).toContain('2025')
- expect(result).toContain('10')
+ expect(result).toContain('Oct')
expect(result).toContain('02')
})
})
@@ -367,3 +388,118 @@ describe('formatUptime', () => {
expect(result).toBe('59 minutes')
})
})
+
+describe('formatTimeAgo', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-03-12T12:00:00Z'))
+ mockT.mockClear()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should return "Just now" for dates less than 60 seconds ago', () => {
+ const result = formatTimeAgo('2026-03-12T11:59:30Z', mockT as any) // 30 seconds ago
+ expect(result).toBe('Just now')
+ })
+
+ it('should return "Just now" for dates exactly now', () => {
+ const result = formatTimeAgo('2026-03-12T12:00:00Z', mockT as any)
+ expect(result).toBe('Just now')
+ })
+
+ it('should return "Just now" for future dates', () => {
+ const result = formatTimeAgo('2026-03-12T13:00:00Z', mockT as any)
+ expect(result).toBe('Just now')
+ })
+
+ it('should return a dash for invalid date strings', () => {
+ const result = formatTimeAgo('not-a-date', mockT as any)
+ expect(result).toBe('-')
+ })
+
+ it('should return minutes ago for 1 minute', () => {
+ const result = formatTimeAgo('2026-03-12T11:59:00Z', mockT as any) // 60 seconds ago
+ expect(result).toBe('1 minute ago')
+ })
+
+ it('should return minutes ago for multiple minutes', () => {
+ const result = formatTimeAgo('2026-03-12T11:51:00Z', mockT as any) // 9 minutes ago
+ expect(result).toBe('9 minutes ago')
+ })
+
+ it('should return minutes ago for 59 minutes', () => {
+ const result = formatTimeAgo('2026-03-12T11:01:00Z', mockT as any) // 59 minutes ago
+ expect(result).toBe('59 minutes ago')
+ })
+
+ it('should return hours ago for 1 hour', () => {
+ const result = formatTimeAgo('2026-03-12T11:00:00Z', mockT as any) // 1 hour ago
+ expect(result).toBe('1 hour ago')
+ })
+
+ it('should return hours ago for multiple hours', () => {
+ const result = formatTimeAgo('2026-03-12T09:00:00Z', mockT as any) // 3 hours ago
+ expect(result).toBe('3 hours ago')
+ })
+
+ it('should return days ago for 1 day', () => {
+ const result = formatTimeAgo('2026-03-11T12:00:00Z', mockT as any) // 1 day ago
+ expect(result).toBe('1 day ago')
+ })
+
+ it('should return days ago for multiple days', () => {
+ const result = formatTimeAgo('2026-03-10T12:00:00Z', mockT as any) // 2 days ago
+ expect(result).toBe('2 days ago')
+ })
+
+ it('should return weeks ago for 1 week', () => {
+ const result = formatTimeAgo('2026-03-05T12:00:00Z', mockT as any) // 7 days ago
+ expect(result).toBe('1 week ago')
+ })
+
+ it('should return weeks ago for multiple weeks', () => {
+ const result = formatTimeAgo('2026-02-26T12:00:00Z', mockT as any) // 14 days ago
+ expect(result).toBe('2 weeks ago')
+ })
+
+ it('should return months ago for 1 month', () => {
+ const result = formatTimeAgo('2026-02-10T12:00:00Z', mockT as any) // 30 days ago
+ expect(result).toBe('1 month ago')
+ })
+
+ it('should return months ago for multiple months', () => {
+ const result = formatTimeAgo('2025-12-12T12:00:00Z', mockT as any) // 90 days ago
+ expect(result).toBe('3 months ago')
+ })
+
+ it('should return years ago for 1 year', () => {
+ const result = formatTimeAgo('2025-03-12T12:00:00Z', mockT as any) // 365 days ago
+ expect(result).toBe('1 year ago')
+ })
+
+ it('should return years ago for multiple years', () => {
+ const result = formatTimeAgo('2024-03-12T12:00:00Z', mockT as any) // ~730 days ago
+ expect(result).toBe('2 years ago')
+ })
+
+ it('should return duration without suffix when suffix is false', () => {
+ const result = formatTimeAgo('2026-03-12T09:00:00Z', mockT as any, { suffix: false })
+ expect(result).toBe('3 hours')
+ })
+
+ it('should return "Just now" even when suffix is false', () => {
+ const result = formatTimeAgo('2026-03-12T11:59:30Z', mockT as any, { suffix: false })
+ expect(result).toBe('Just now')
+ })
+
+ it('should return duration without suffix for each unit', () => {
+ expect(formatTimeAgo('2026-03-12T11:51:00Z', mockT as any, { suffix: false })).toBe('9 minutes')
+ expect(formatTimeAgo('2026-03-11T12:00:00Z', mockT as any, { suffix: false })).toBe('1 day')
+ expect(formatTimeAgo('2026-03-05T12:00:00Z', mockT as any, { suffix: false })).toBe('1 week')
+ expect(formatTimeAgo('2026-02-10T12:00:00Z', mockT as any, { suffix: false })).toBe('1 month')
+ expect(formatTimeAgo('2025-03-12T12:00:00Z', mockT as any, { suffix: false })).toBe('1 year')
+ })
+})
diff --git a/frontend/src/lib/dateTime.ts b/frontend/src/lib/dateTime.ts
index 7f5511b6a..825f2d75e 100644
--- a/frontend/src/lib/dateTime.ts
+++ b/frontend/src/lib/dateTime.ts
@@ -10,7 +10,7 @@ export function formatDateTime(dateTime: Date, locale: string): string {
export function formatDateTimeNoSeconds(dateTime: Date, locale: string): string {
return dateTime.toLocaleString(locale, {
year: 'numeric',
- month: '2-digit',
+ month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
@@ -37,7 +37,7 @@ export function formatSeconds(totalSeconds: number, t: ComposerTranslation) {
return t('time.seconds', totalSeconds)
}
- if (totalSeconds < 3600) {
+ if (totalSeconds < 60 * 60) {
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
@@ -48,8 +48,8 @@ export function formatSeconds(totalSeconds: number, t: ComposerTranslation) {
return `${t('time.minutes', minutes)}, ${t('time.seconds', seconds)}`
}
- const hours = Math.floor(totalSeconds / 3600)
- const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const hours = Math.floor(totalSeconds / (60 * 60))
+ const minutes = Math.floor((totalSeconds % (60 * 60)) / 60)
const seconds = totalSeconds % 60
if (minutes === 0 && seconds === 0) {
@@ -75,24 +75,81 @@ export function formatUptime(uptimeSeconds: number, t: ComposerTranslation): str
return t('time.seconds', uptimeSeconds)
}
- if (uptimeSeconds < 3600) {
+ if (uptimeSeconds < 60 * 60) {
const minutes = Math.floor(uptimeSeconds / 60)
return t('time.minutes', minutes)
}
- if (uptimeSeconds < 86400) {
- const hours = Math.floor(uptimeSeconds / 3600)
- const minutes = Math.floor((uptimeSeconds % 3600) / 60)
+ if (uptimeSeconds < 60 * 60 * 24) {
+ const hours = Math.floor(uptimeSeconds / (60 * 60))
+ const minutes = Math.floor((uptimeSeconds % (60 * 60)) / 60)
if (minutes === 0) {
return t('time.hours', hours)
}
return `${t('time.hours', hours)}, ${t('time.minutes', minutes)}`
}
- const days = Math.floor(uptimeSeconds / 86400)
- const hours = Math.floor((uptimeSeconds % 86400) / 3600)
+ const days = Math.floor(uptimeSeconds / (60 * 60 * 24))
+ const hours = Math.floor((uptimeSeconds % (60 * 60 * 24)) / (60 * 60))
if (hours === 0) {
return t('time.days', days)
}
return `${t('time.days', days)}, ${t('time.hours', hours)}`
}
+
+/**
+ * Format an ISO date string as a human-readable relative time string
+ * (e.g. "3 hours ago", "Just now")
+ *
+ * @param isoDate - ISO 8601 date string
+ * @param t - vue-i18n translation function
+ * @param options.suffix - whether to wrap the duration with the "ago" suffix (default: true)
+ */
+export function formatTimeAgo(
+ isoDate: string,
+ t: ComposerTranslation,
+ options: { suffix?: boolean } = {},
+): string {
+ const { suffix = true } = options
+ const date = new Date(isoDate)
+
+ if (isNaN(date.getTime())) {
+ return '-'
+ }
+
+ const diffSeconds = Math.floor((Date.now() - date.getTime()) / 1000)
+
+ if (diffSeconds < 60) {
+ return t('time.just_now')
+ }
+
+ const formatElapsed = (time: string) => (suffix ? t('time.ago', { time }) : time)
+
+ if (diffSeconds < 60 * 60) {
+ const minutes = Math.floor(diffSeconds / 60)
+ return formatElapsed(t('time.minutes', minutes))
+ }
+
+ if (diffSeconds < 60 * 60 * 24) {
+ const hours = Math.floor(diffSeconds / (60 * 60))
+ return formatElapsed(t('time.hours', hours))
+ }
+
+ if (diffSeconds < 60 * 60 * 24 * 7) {
+ const days = Math.floor(diffSeconds / (60 * 60 * 24))
+ return formatElapsed(t('time.days', days))
+ }
+
+ if (diffSeconds < 60 * 60 * 24 * 30) {
+ const weeks = Math.floor(diffSeconds / (60 * 60 * 24 * 7))
+ return formatElapsed(t('time.weeks', weeks))
+ }
+
+ if (diffSeconds < 60 * 60 * 24 * 365) {
+ const months = Math.floor(diffSeconds / (60 * 60 * 24 * 30))
+ return formatElapsed(t('time.months', months))
+ }
+
+ const years = Math.floor(diffSeconds / (60 * 60 * 24 * 365))
+ return formatElapsed(t('time.years', years))
+}
diff --git a/frontend/src/lib/systems/inventory.ts b/frontend/src/lib/systems/inventory.ts
index de5801ae5..7ef40e1ac 100644
--- a/frontend/src/lib/systems/inventory.ts
+++ b/frontend/src/lib/systems/inventory.ts
@@ -12,9 +12,103 @@ interface InventoryData {
system_id: string
timestamp: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- data: any // The structure of inventory data can be complex and varied
+ data: any //// improve typing
}
+//// move specific facts to separate files?
+
+// interface NsecFacts { ////
+// distro: {
+// name: string
+// version: string
+// }
+// memory: {
+// // swap: { ////
+// // used_bytes: number
+// // available_bytes: number
+// // }
+// // ...
+// }
+// features: NsecFeatures
+// }
+
+// interface NsecFeatures {
+// ha: {
+// vips: number
+// enabled: boolean
+// }
+// ui: {
+// luci: boolean
+// port443: boolean
+// port9090: boolean
+// }
+// dpi: {
+// rules: number
+// enabled: boolean
+// }
+// qos: {
+// count: number
+// rules: unknown[]
+// }
+// ddns: {
+// enabled: boolean
+// }
+// snmp: {
+// enabled: boolean
+// }
+// ipsec: {
+// count: number
+// }
+// snort: {
+// policy: string
+// enabled: boolean
+// oink_enabled: boolean
+// disabled_rules: number
+// bypass_dst_ipv4: number
+// bypass_dst_ipv6: number
+// bypass_src_ipv4: number
+// bypass_src_ipv6: number
+// suppressed_rules: number
+// }
+// adblock: {
+// enabled: boolean
+// community: number
+// enterprise: number
+// }
+// backups: {
+// passphrase_date: number
+// backup_passphrase: boolean
+// }
+// hotspot: {
+// server: string
+// enabled: boolean
+// interface: string
+// }
+// netifyd: {
+// enabled: boolean
+// }
+// network: NsecNetworkFeature
+// }
+
+// interface NsecNetworkFeature {
+// zones: {
+// ipv4: number
+// ipv6: number
+// name: string
+// }[]
+// route_info: {
+// count_ipv4_route: number
+// count_ipv6_route: number
+// }
+// interface_counts: {
+// bonds: number
+// vlans: number
+// bridges: number
+// }
+// zone_network_counts: Record
+// }
+
+//// fix network card, then remove
export interface EsmithConfiguration {
name: string
type: string
diff --git a/frontend/src/lib/systems/inventoryChanges.ts b/frontend/src/lib/systems/inventoryChanges.ts
new file mode 100644
index 000000000..909f6181a
--- /dev/null
+++ b/frontend/src/lib/systems/inventoryChanges.ts
@@ -0,0 +1,36 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import axios from 'axios'
+import { API_URL } from '../config'
+import { useLoginStore } from '@/stores/login'
+import { type InventoryDiffCategory, type InventoryDiffSeverity } from './inventoryDiffs'
+
+export const INVENTORY_CHANGES_KEY = 'inventoryChanges'
+
+export interface InventoryChanges {
+ system_id: string
+ total_changes: number
+ recent_changes: number
+ last_inventory_time: string
+ has_critical_changes: boolean
+ has_alerts: boolean
+ changes_by_category: Partial>
+ changes_by_severity: Partial>
+}
+
+interface InventoryChangesResponse {
+ code: number
+ message: string
+ data: InventoryChanges | null
+}
+
+export const getInventoryChanges = (systemId: string) => {
+ const loginStore = useLoginStore()
+
+ return axios
+ .get(`${API_URL}/systems/${systemId}/inventory/changes`, {
+ headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+ })
+ .then((res) => res.data.data)
+}
diff --git a/frontend/src/lib/systems/inventoryDiffs.ts b/frontend/src/lib/systems/inventoryDiffs.ts
new file mode 100644
index 000000000..e2cfe4429
--- /dev/null
+++ b/frontend/src/lib/systems/inventoryDiffs.ts
@@ -0,0 +1,118 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import axios from 'axios'
+import { API_URL } from '../config'
+import { useLoginStore } from '@/stores/login'
+import { type Pagination } from '../common'
+
+export const INVENTORY_DIFFS_KEY = 'inventoryDiffs'
+export const INVENTORY_DIFFS_TABLE_ID = 'inventoryDiffsTable'
+
+export type InventoryDiffSeverity = 'low' | 'medium' | 'high' | 'critical'
+
+export type InventoryDiffCategory =
+ | 'os'
+ | 'hardware'
+ | 'network'
+ | 'security'
+ | 'backup'
+ | 'features'
+ | 'modules'
+ | 'cluster'
+ | 'nodes'
+ | 'system'
+
+export type InventoryDiffType = 'create' | 'update' | 'delete'
+
+export interface InventoryDiff {
+ id: number
+ system_id: string
+ previous_inventory_id: number | null
+ inventory_id: number
+ diff_type: InventoryDiffType
+ field_path: string
+ previous_value: unknown
+ current_value: unknown
+ severity: InventoryDiffSeverity
+ category: InventoryDiffCategory
+ notification_sent: boolean
+ created_at: string
+}
+
+interface InventoryDiffsResponse {
+ code: number
+ message: string
+ data: {
+ diffs: InventoryDiff[]
+ pagination: Pagination
+ }
+}
+
+const getInventoryDiffsQueryStringParams = (
+ pageNum: number,
+ pageSize: number,
+ severity: InventoryDiffSeverity[],
+ category: InventoryDiffCategory[],
+ diffType: InventoryDiffType[],
+ inventoryId: number[],
+ fromDate: string,
+ toDate: string,
+ search: string,
+) => {
+ const searchParams = new URLSearchParams({
+ page: pageNum.toString(),
+ page_size: pageSize.toString(),
+ })
+
+ severity.forEach((s) => searchParams.append('severity', s))
+ category.forEach((c) => searchParams.append('category', c))
+ diffType.forEach((d) => searchParams.append('diff_type', d))
+ inventoryId.forEach((id) => searchParams.append('inventory_id', id.toString()))
+
+ if (fromDate.trim()) {
+ searchParams.append('from_date', fromDate + 'T00:00:00Z')
+ }
+
+ if (toDate.trim()) {
+ searchParams.append('to_date', toDate + 'T23:59:59Z')
+ }
+
+ if (search.trim()) {
+ searchParams.append('search', search)
+ }
+
+ return searchParams.toString()
+}
+
+export const getInventoryDiffs = (
+ systemId: string,
+ pageNum: number,
+ pageSize: number,
+ severity: InventoryDiffSeverity[],
+ category: InventoryDiffCategory[],
+ diffType: InventoryDiffType[],
+ inventoryId: number[],
+ fromDate: string,
+ toDate: string,
+ search: string,
+) => {
+ const loginStore = useLoginStore()
+ const queryString = getInventoryDiffsQueryStringParams(
+ pageNum,
+ pageSize,
+ severity,
+ category,
+ diffType,
+ inventoryId,
+ fromDate,
+ toDate,
+ search,
+ )
+
+ return axios
+ .get(`${API_URL}/systems/${systemId}/inventory/diffs?${queryString}`, {
+ headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+ })
+ .then((res) => res.data.data)
+}
diff --git a/frontend/src/lib/systems/inventoryTimeline.ts b/frontend/src/lib/systems/inventoryTimeline.ts
new file mode 100644
index 000000000..8a4eb87d9
--- /dev/null
+++ b/frontend/src/lib/systems/inventoryTimeline.ts
@@ -0,0 +1,107 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import axios from 'axios'
+import { API_URL } from '../config'
+import { useLoginStore } from '@/stores/login'
+import { type Pagination } from '../common'
+import {
+ type InventoryDiffCategory,
+ type InventoryDiffSeverity,
+ type InventoryDiffType,
+} from './inventoryDiffs'
+
+export const INVENTORY_TIMELINE_KEY = 'inventoryTimeline'
+export const INVENTORY_TIMELINE_TABLE_ID = 'inventoryTimelineTable'
+
+export interface InventoryTimelineSummary {
+ total: number
+ critical: number
+ high: number
+ medium: number
+ low: number
+}
+
+export interface InventoryTimelineGroup {
+ date: string
+ inventory_count: number
+ change_count: number
+ inventory_ids: number[]
+}
+
+interface InventoryTimelineResponse {
+ code: number
+ message: string
+ data: {
+ summary: InventoryTimelineSummary
+ groups: InventoryTimelineGroup[]
+ pagination: Pagination
+ }
+}
+
+const getInventoryTimelineQueryStringParams = (
+ pageNum: number,
+ pageSize: number,
+ severity: InventoryDiffSeverity[],
+ category: InventoryDiffCategory[],
+ diffType: InventoryDiffType[],
+ fromDate: string,
+ toDate: string,
+ search: string,
+) => {
+ const searchParams = new URLSearchParams({
+ page: pageNum.toString(),
+ page_size: pageSize.toString(),
+ })
+
+ severity.forEach((s) => searchParams.append('severity', s))
+ category.forEach((c) => searchParams.append('category', c))
+ diffType.forEach((d) => searchParams.append('diff_type', d))
+
+ if (fromDate.trim()) {
+ searchParams.append('from_date', fromDate + 'T00:00:00Z')
+ }
+
+ if (toDate.trim()) {
+ searchParams.append('to_date', toDate + 'T23:59:59Z')
+ }
+
+ if (search.trim()) {
+ searchParams.append('search', search)
+ }
+
+ return searchParams.toString()
+}
+
+export const getInventoryTimeline = (
+ systemId: string,
+ pageNum: number,
+ pageSize: number,
+ severity: InventoryDiffSeverity[],
+ category: InventoryDiffCategory[],
+ diffType: InventoryDiffType[],
+ fromDate: string,
+ toDate: string,
+ search: string,
+) => {
+ const loginStore = useLoginStore()
+ const queryString = getInventoryTimelineQueryStringParams(
+ pageNum,
+ pageSize,
+ severity,
+ category,
+ diffType,
+ fromDate,
+ toDate,
+ search,
+ )
+
+ return axios
+ .get(
+ `${API_URL}/systems/${systemId}/inventory/timeline?${queryString}`,
+ {
+ headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+ },
+ )
+ .then((res) => res.data.data)
+}
diff --git a/frontend/src/lib/systems/systems.ts b/frontend/src/lib/systems/systems.ts
index c5faa2e48..f4f46acb8 100644
--- a/frontend/src/lib/systems/systems.ts
+++ b/frontend/src/lib/systems/systems.ts
@@ -13,11 +13,7 @@ export const SYSTEMS_KEY = 'systems'
export const SYSTEMS_TOTAL_KEY = 'systemsTotal'
export const SYSTEMS_TABLE_ID = 'systemsTable'
-export type SystemStatus = 'online' | 'offline' | 'unknown' | 'deleted' | 'suspended'
-
-const systemStatusOptions = ['online', 'offline', 'unknown', 'deleted', 'suspended']
-
-const SystemStatusSchema = v.picklist(systemStatusOptions)
+const SystemStatusSchema = v.picklist(['active', 'inactive', 'unknown', 'deleted', 'suspended'])
export const CreateSystemSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty('systems.name_cannot_be_empty')),
@@ -42,11 +38,15 @@ export const SystemSchema = v.object({
version: v.string(),
created_at: v.string(),
updated_at: v.string(),
+ registered_at: v.optional(v.string()),
system_key: v.optional(v.string()),
system_secret: v.string(),
suspended_at: v.optional(v.string()),
+ last_inventory: v.optional(v.string()),
+ rebranding_enabled: v.optional(v.boolean()),
organization: v.object({
id: v.string(),
+ logto_id: v.string(),
name: v.string(),
type: v.string(),
}),
@@ -63,6 +63,7 @@ export const SystemSchema = v.object({
export type CreateSystem = v.InferOutput
export type EditSystem = v.InferOutput
export type System = v.InferOutput
+export type SystemStatus = v.InferOutput
interface SystemsResponse {
code: number
diff --git a/frontend/src/lib/users/users.ts b/frontend/src/lib/users/users.ts
index a71ac9d2e..7c2d2a1ac 100644
--- a/frontend/src/lib/users/users.ts
+++ b/frontend/src/lib/users/users.ts
@@ -13,15 +13,15 @@ export const USERS_TABLE_ID = 'usersTable'
export type UserStatus = 'enabled' | 'suspended' | 'deleted'
+export const PhoneNumberSchema = v.pipe(
+ v.string(),
+ v.regex(/^\+?[\d\s\-\(\)]{7,20}$/, 'users.phone_invalid_format'),
+)
+
export const CreateUserSchema = v.object({
email: v.pipe(v.string(), v.nonEmpty('users.email_required'), v.email('users.email_invalid')),
name: v.pipe(v.string(), v.nonEmpty('users.name_cannot_be_empty')),
- phone: v.optional(
- v.union([
- v.literal(''),
- v.pipe(v.string(), v.regex(/^\+?[\d\s\-\(\)]{7,20}$/, 'users.phone_invalid_format')),
- ]),
- ),
+ phone: v.optional(v.union([v.literal(''), PhoneNumberSchema])),
user_role_ids: v.pipe(
v.array(v.string()),
v.minLength(1, 'users.user_role_ids_at_least_one_role_is_required'),
diff --git a/frontend/src/queries/systems/inventoryChanges.ts b/frontend/src/queries/systems/inventoryChanges.ts
new file mode 100644
index 000000000..427cc5b9d
--- /dev/null
+++ b/frontend/src/queries/systems/inventoryChanges.ts
@@ -0,0 +1,28 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { getInventoryChanges, INVENTORY_CHANGES_KEY } from '@/lib/systems/inventoryChanges'
+import { canReadSystems } from '@/lib/permissions'
+import { useLoginStore } from '@/stores/login'
+import { defineQuery, useQuery } from '@pinia/colada'
+import { useRoute } from 'vue-router'
+
+export const useInventoryChanges = defineQuery(() => {
+ const loginStore = useLoginStore()
+ const route = useRoute()
+
+ const { state, asyncStatus, ...rest } = useQuery({
+ key: () => [INVENTORY_CHANGES_KEY, route.params.systemId],
+ enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId,
+ query: () => {
+ const apiCall = getInventoryChanges(route.params.systemId as string)
+ return apiCall
+ },
+ })
+
+ return {
+ ...rest,
+ state,
+ asyncStatus,
+ }
+})
diff --git a/frontend/src/queries/systems/inventoryDiffs.ts b/frontend/src/queries/systems/inventoryDiffs.ts
new file mode 100644
index 000000000..3e6c49096
--- /dev/null
+++ b/frontend/src/queries/systems/inventoryDiffs.ts
@@ -0,0 +1,188 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import {
+ INVENTORY_DIFFS_KEY,
+ INVENTORY_DIFFS_TABLE_ID,
+ getInventoryDiffs,
+ type InventoryDiffCategory,
+ type InventoryDiffSeverity,
+ type InventoryDiffType,
+} from '@/lib/systems/inventoryDiffs'
+import { MIN_SEARCH_LENGTH } from '@/lib/common'
+import { canReadSystems } from '@/lib/permissions'
+import { DEFAULT_PAGE_SIZE, loadPageSizeFromStorage } from '@/lib/tablePageSize'
+import { useLoginStore } from '@/stores/login'
+import { defineQuery, useQuery } from '@pinia/colada'
+import { useDebounceFn } from '@vueuse/core'
+import { computed, ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
+
+//// currently unused?
+export const useInventoryDiffs = defineQuery(() => {
+ const loginStore = useLoginStore()
+ const route = useRoute()
+ const pageNum = ref(1)
+ const pageSize = ref(DEFAULT_PAGE_SIZE)
+ const severityFilter = ref([])
+ const categoryFilter = ref([])
+ const diffTypeFilter = ref([])
+ const inventoryIdFilter = ref([])
+ const fromDate = ref('')
+ const toDate = ref('')
+ const textFilter = ref('')
+ const debouncedTextFilter = ref('')
+
+ const { state, asyncStatus, ...rest } = useQuery({
+ key: () => [
+ INVENTORY_DIFFS_KEY,
+ {
+ systemId: route.params.systemId,
+ pageNum: pageNum.value,
+ pageSize: pageSize.value,
+ severityFilter: severityFilter.value,
+ categoryFilter: categoryFilter.value,
+ diffTypeFilter: diffTypeFilter.value,
+ inventoryIdFilter: inventoryIdFilter.value,
+ fromDate: fromDate.value,
+ toDate: toDate.value,
+ search: debouncedTextFilter.value,
+ },
+ ],
+ enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId,
+ staleTime: 0,
+ gcTime: 0,
+ query: () => {
+ const apiCall = getInventoryDiffs(
+ route.params.systemId as string,
+ pageNum.value,
+ pageSize.value,
+ severityFilter.value,
+ categoryFilter.value,
+ diffTypeFilter.value,
+ inventoryIdFilter.value,
+ fromDate.value,
+ toDate.value,
+ debouncedTextFilter.value,
+ )
+ return apiCall
+ },
+ })
+
+ const areDefaultFiltersApplied = computed(() => {
+ return (
+ severityFilter.value.length === 0 &&
+ categoryFilter.value.length === 0 &&
+ diffTypeFilter.value.length === 0 &&
+ inventoryIdFilter.value.length === 0 &&
+ !fromDate.value &&
+ !toDate.value &&
+ !debouncedTextFilter.value
+ )
+ })
+
+ // load table page size from storage
+ watch(
+ () => loginStore.userInfo?.email,
+ (email) => {
+ if (email) {
+ pageSize.value = loadPageSizeFromStorage(INVENTORY_DIFFS_TABLE_ID)
+ }
+ },
+ { immediate: true },
+ )
+
+ // reset to first page when page size changes
+ watch(
+ () => pageSize.value,
+ () => {
+ pageNum.value = 1
+ },
+ )
+
+ // reset to first page when any filter changes
+ watch(
+ () => severityFilter.value,
+ () => {
+ pageNum.value = 1
+ },
+ { deep: true },
+ )
+
+ watch(
+ () => categoryFilter.value,
+ () => {
+ pageNum.value = 1
+ },
+ { deep: true },
+ )
+
+ watch(
+ () => diffTypeFilter.value,
+ () => {
+ pageNum.value = 1
+ },
+ { deep: true },
+ )
+
+ watch(
+ () => inventoryIdFilter.value,
+ () => {
+ pageNum.value = 1
+ },
+ { deep: true },
+ )
+
+ watch(
+ () => fromDate.value,
+ () => {
+ pageNum.value = 1
+ },
+ )
+
+ watch(
+ () => toDate.value,
+ () => {
+ pageNum.value = 1
+ },
+ )
+
+ const resetFilters = () => {
+ severityFilter.value = []
+ categoryFilter.value = []
+ diffTypeFilter.value = []
+ inventoryIdFilter.value = []
+ fromDate.value = ''
+ toDate.value = ''
+ textFilter.value = ''
+ debouncedTextFilter.value = ''
+ }
+
+ watch(
+ () => textFilter.value,
+ useDebounceFn(() => {
+ if (textFilter.value.length === 0 || textFilter.value.length >= MIN_SEARCH_LENGTH) {
+ debouncedTextFilter.value = textFilter.value
+ pageNum.value = 1
+ }
+ }, 500),
+ )
+
+ return {
+ ...rest,
+ state,
+ asyncStatus,
+ pageNum,
+ pageSize,
+ severityFilter,
+ categoryFilter,
+ diffTypeFilter,
+ inventoryIdFilter,
+ fromDate,
+ toDate,
+ textFilter,
+ debouncedTextFilter,
+ areDefaultFiltersApplied,
+ resetFilters,
+ }
+})
diff --git a/frontend/src/queries/systems/inventoryTimeline.ts b/frontend/src/queries/systems/inventoryTimeline.ts
new file mode 100644
index 000000000..5a5ba5146
--- /dev/null
+++ b/frontend/src/queries/systems/inventoryTimeline.ts
@@ -0,0 +1,125 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import {
+ type InventoryDiffCategory,
+ type InventoryDiffSeverity,
+ type InventoryDiffType,
+} from '@/lib/systems/inventoryDiffs'
+import { INVENTORY_TIMELINE_KEY, getInventoryTimeline } from '@/lib/systems/inventoryTimeline'
+import { MIN_SEARCH_LENGTH } from '@/lib/common'
+import { canReadSystems } from '@/lib/permissions'
+import { useLoginStore } from '@/stores/login'
+import { defineQuery, useInfiniteQuery } from '@pinia/colada'
+import { useDebounceFn } from '@vueuse/core'
+import { computed, ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
+
+const TIMELINE_PAGE_SIZE = 5 //// 20
+
+export const useInventoryTimeline = defineQuery(() => {
+ const loginStore = useLoginStore()
+ const route = useRoute()
+ const severityFilter = ref([])
+ const categoryFilter = ref([])
+ const diffTypeFilter = ref([])
+ const fromDate = ref('')
+ const toDate = ref('')
+ const textFilter = ref('')
+ const debouncedTextFilter = ref('')
+
+ const { state, asyncStatus, hasNextPage, loadNextPage } = useInfiniteQuery({
+ key: () => [
+ INVENTORY_TIMELINE_KEY,
+ {
+ systemId: route.params.systemId,
+ severityFilter: severityFilter.value,
+ categoryFilter: categoryFilter.value,
+ diffTypeFilter: diffTypeFilter.value,
+ fromDate: fromDate.value,
+ toDate: toDate.value,
+ search: debouncedTextFilter.value,
+ },
+ ],
+ enabled: () => !!loginStore.jwtToken && canReadSystems() && !!route.params.systemId,
+ staleTime: 0,
+ gcTime: 0,
+ initialPageParam: 1,
+ query: ({ pageParam }) => {
+ const apiCall = getInventoryTimeline(
+ route.params.systemId as string,
+ pageParam,
+ TIMELINE_PAGE_SIZE,
+ severityFilter.value,
+ categoryFilter.value,
+ diffTypeFilter.value,
+ fromDate.value,
+ toDate.value,
+ debouncedTextFilter.value,
+ )
+ return apiCall
+ },
+ getNextPageParam: (lastPage) =>
+ lastPage.pagination.has_next ? lastPage.pagination.page + 1 : null,
+ })
+
+ const allInventoryIds = computed(() =>
+ (state.value.data?.pages ?? []).flatMap((page) =>
+ page.groups.flatMap((group) => group.inventory_ids),
+ ),
+ )
+
+ const allGroups = computed(() => (state.value.data?.pages ?? []).flatMap((page) => page.groups))
+
+ // Summary from the first page — represents overall totals for the current system
+ const summary = computed(() => state.value.data?.pages[0]?.summary ?? null)
+
+ const areDefaultFiltersApplied = computed(() => {
+ return (
+ severityFilter.value.length === 0 &&
+ categoryFilter.value.length === 0 &&
+ diffTypeFilter.value.length === 0 &&
+ !fromDate.value &&
+ !toDate.value &&
+ !debouncedTextFilter.value
+ )
+ })
+
+ watch(
+ () => textFilter.value,
+ useDebounceFn(() => {
+ if (textFilter.value.length === 0 || textFilter.value.length >= MIN_SEARCH_LENGTH) {
+ debouncedTextFilter.value = textFilter.value
+ }
+ }, 500),
+ )
+
+ const resetFilters = () => {
+ severityFilter.value = []
+ categoryFilter.value = []
+ diffTypeFilter.value = []
+ fromDate.value = ''
+ toDate.value = ''
+ textFilter.value = ''
+ debouncedTextFilter.value = ''
+ }
+
+ return {
+ state,
+ asyncStatus,
+ hasNextPage,
+ loadNextPage,
+ severityFilter,
+ categoryFilter,
+ diffTypeFilter,
+ fromDate,
+ toDate,
+ textFilter,
+ debouncedTextFilter,
+ areDefaultFiltersApplied,
+ resetFilters,
+ allInventoryIds,
+ allGroups,
+ summary,
+ }
+})
diff --git a/frontend/src/queries/systems/systems.ts b/frontend/src/queries/systems/systems.ts
index e51d0eded..4eb1292c6 100644
--- a/frontend/src/queries/systems/systems.ts
+++ b/frontend/src/queries/systems/systems.ts
@@ -26,7 +26,7 @@ export const useSystems = defineQuery(() => {
const productFilter = ref([])
const createdByFilter = ref([])
const versionFilter = ref([])
- const statusFilter = ref(['online', 'offline', 'unknown', 'suspended'])
+ const statusFilter = ref(['active', 'inactive', 'unknown', 'suspended'])
const organizationFilter = ref([])
const sortBy = ref('name')
const sortDescending = ref(false)
@@ -71,8 +71,8 @@ export const useSystems = defineQuery(() => {
createdByFilter.value.length === 0 &&
organizationFilter.value.length === 0 &&
statusFilter.value.length === 4 &&
- statusFilter.value.includes('online') &&
- statusFilter.value.includes('offline') &&
+ statusFilter.value.includes('active') &&
+ statusFilter.value.includes('inactive') &&
statusFilter.value.includes('unknown') &&
statusFilter.value.includes('suspended') &&
!statusFilter.value.includes('deleted')
@@ -156,7 +156,7 @@ export const useSystems = defineQuery(() => {
productFilter.value = []
versionFilter.value = []
createdByFilter.value = []
- statusFilter.value = ['online', 'offline', 'unknown', 'suspended']
+ statusFilter.value = ['active', 'inactive', 'unknown', 'suspended']
organizationFilter.value = []
}
diff --git a/frontend/src/views/SystemDetailView.vue b/frontend/src/views/SystemDetailView.vue
index 47571509c..59b529c85 100644
--- a/frontend/src/views/SystemDetailView.vue
+++ b/frontend/src/views/SystemDetailView.vue
@@ -18,13 +18,17 @@ import { useSystemDetail } from '@/queries/systems/systemDetail'
import { useTabs } from '@/composables/useTabs'
import { useI18n } from 'vue-i18n'
import SystemOverviewPanel from '@/components/systems/SystemOverviewPanel.vue'
+import SystemChangeHistoryPanel from '@/components/systems/SystemChangeHistoryPanel.vue'
import { useLatestInventory } from '@/queries/systems/latestInventory'
import { computed } from 'vue'
const { t } = useI18n()
const { state: systemDetail } = useSystemDetail()
const { state: latestInventory } = useLatestInventory()
-const { tabs, selectedTab } = useTabs([{ name: 'overview', label: t('system_detail.overview') }])
+const { tabs, selectedTab } = useTabs([
+ { name: 'overview', label: t('system_detail.overview') },
+ { name: 'change_history', label: t('system_detail.change_history') },
+])
const systemUrl = computed(() => {
if (!systemDetail.value.data?.fqdn) {
@@ -114,5 +118,6 @@ const openSystem = () => {
@select-tab="selectedTab = $event"
/>
+
diff --git a/proxy/.render-build-trigger b/proxy/.render-build-trigger
index 3bb7a5909..89e6edccd 100644
--- a/proxy/.render-build-trigger
+++ b/proxy/.render-build-trigger
@@ -2,9 +2,9 @@
# This file is used to force Docker service rebuilds in PR previews
# Modify LAST_UPDATE to trigger rebuilds
-LAST_UPDATE=2026-02-10T12:10:22Z
+LAST_UPDATE=2026-03-09T09:01:19Z
# Instructions:
# 1. To force rebuild of Docker services in a PR, update LAST_UPDATE
-# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-02-10T12:10:22Z
+# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-03-09T09:01:19Z
# 2. Commit and push changes to trigger Docker rebuilds
\ No newline at end of file
diff --git a/services/mimir/.render-build-trigger b/services/mimir/.render-build-trigger
index 9249691c6..f4eef0389 100644
--- a/services/mimir/.render-build-trigger
+++ b/services/mimir/.render-build-trigger
@@ -2,7 +2,7 @@
# This file is used to force Docker service rebuilds in PR previews
# Modify LAST_UPDATE to trigger rebuilds
-LAST_UPDATE=2025-10-22T15:22:55Z
+LAST_UPDATE=2026-03-09T09:01:19Z
# Instructions:
# 1. To force rebuild of Docker services in a PR, update LAST_UPDATE