diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index ad5244136..36cb008ce 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -194,31 +194,6 @@
"count": 2
}
},
- "src/components/standalone/dashboard/InternetConnectionCard.vue": {
- "@typescript-eslint/no-explicit-any": {
- "count": 4
- }
- },
- "src/components/standalone/dashboard/OpenVpnTunnelOrIpsecCard.vue": {
- "@typescript-eslint/no-explicit-any": {
- "count": 1
- }
- },
- "src/components/standalone/dashboard/ServiceCard.vue": {
- "@typescript-eslint/no-explicit-any": {
- "count": 4
- }
- },
- "src/components/standalone/dashboard/SystemInfoCard.vue": {
- "@typescript-eslint/no-explicit-any": {
- "count": 2
- }
- },
- "src/components/standalone/dashboard/ThreatShieldIpCard.vue": {
- "@typescript-eslint/no-explicit-any": {
- "count": 6
- }
- },
"src/components/standalone/dns_dhcp/CreateOrEditDnsRecordDrawer.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
diff --git a/src/components/standalone/dashboard/BackupStatusCard.vue b/src/components/standalone/dashboard/BackupStatusCard.vue
index f141d65f5..af6c0f0c1 100644
--- a/src/components/standalone/dashboard/BackupStatusCard.vue
+++ b/src/components/standalone/dashboard/BackupStatusCard.vue
@@ -2,19 +2,21 @@
import {
formatDateLoc,
getAxiosErrorMessage,
- NeBadge,
+ NeBadgeV2,
NeCard,
NeLink
} from '@nethesis/vue-components'
import { useSubscriptionStore } from '@/stores/standalone/subscription.ts'
import { getStandaloneRoutePrefix } from '@/lib/router.ts'
import { useI18n } from 'vue-i18n'
-import { computed, ref, watchEffect } from 'vue'
+import { computed } from 'vue'
import { faClock, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ubusCall } from '@/lib/standalone/ubus.ts'
+import { useQuery } from '@tanstack/vue-query'
import type { AxiosResponse } from 'axios'
import { useBackupsStore } from '@/stores/standalone/backups.ts'
+import { DASHBOARD_REFRESH_INTERVAL } from '@/composables/useDashboardOverview'
type BackupData = {
created: number
@@ -35,31 +37,24 @@ const backups = useBackupsStore()
const { t } = useI18n()
-const latestBackupLoading = ref(true)
-const latestBackupError = ref()
-const lastBackup = ref()
-
-watchEffect(() => {
- if (subscription.isActive) {
- latestBackupError.value = undefined
- latestBackupLoading.value = true
- ubusCall('ns.backup', 'registered-list-backups')
- .then((response: BackupResponse) => {
- const backup = response.data.values.backups.sort((a, b) => b.created - a.created).shift()
- if (backup != undefined) {
- lastBackup.value = formatDateLoc(new Date(backup.created * 1000), 'PPpp')
- } else {
- lastBackup.value = undefined
- }
- })
- .catch(() => {
- lastBackup.value = undefined
- })
- }
+const {
+ data: lastBackup,
+ isPending: latestBackupLoading,
+ error: latestBackupError
+} = useQuery({
+ queryKey: ['dashboard', 'backup', 'last'],
+ queryFn: ({ signal }) =>
+ ubusCall('ns.backup', 'registered-list-backups', {}, { signal }),
+ select: (response) => {
+ const backup = response.data.values.backups.sort((a, b) => b.created - a.created).shift()
+ return backup != undefined ? formatDateLoc(new Date(backup.created * 1000), 'PPpp') : undefined
+ },
+ enabled: () => subscription.isActive,
+ refetchInterval: DASHBOARD_REFRESH_INTERVAL
})
const loading = computed((): boolean => {
- return [subscription.loading, latestBackupLoading.value, backups.loading].every((entry) => entry)
+ return [subscription.loading, latestBackupLoading.value, backups.loading].some((entry) => entry)
})
const error = computed((): string | undefined => {
@@ -101,13 +96,10 @@ const error = computed((): string | undefined => {
{{ t('standalone.backup_and_restore.backup.no_backups') }}
-
+
+
+ {{ t('standalone.backup_and_restore.backup.passphrase_not_configured') }}
+
diff --git a/src/components/standalone/dashboard/HaStatusCard.vue b/src/components/standalone/dashboard/HaStatusCard.vue
index ea8112ecd..e4b56da95 100644
--- a/src/components/standalone/dashboard/HaStatusCard.vue
+++ b/src/components/standalone/dashboard/HaStatusCard.vue
@@ -1,58 +1,65 @@
@@ -43,7 +26,7 @@ watch(
:error-description="errorDescription"
:error-title="errorTitle"
:icon="['fas', 'shield']"
- :loading="ips.loading"
+ :loading="isPending"
:skeleton-lines="2"
>
@@ -51,16 +34,19 @@ watch(
{{ t('standalone.ips.sidebar_title') }}
-
-
-
+
+
+
+ {{ t('standalone.dashboard.active') }}
+
+
+
+ {{ t('standalone.dashboard.inactive') }}
+
- {{ ips.events }}
- {{ t('standalone.ips.events_today', ips.events) }}
+ {{ ips?.events ?? '-' }}
+ {{ t('standalone.ips.events_today', ips?.events ?? 0) }}
diff --git a/src/components/standalone/dashboard/MacBindingStatusCard.vue b/src/components/standalone/dashboard/MacBindingStatusCard.vue
index e6ac524fe..8f3bf6e95 100644
--- a/src/components/standalone/dashboard/MacBindingStatusCard.vue
+++ b/src/components/standalone/dashboard/MacBindingStatusCard.vue
@@ -1,10 +1,13 @@
@@ -65,13 +49,13 @@ const errorDescription = computed((): string => {
{{ t('standalone.dns_dhcp.mac_binding') }}
-
-
-
+
+
+ {{ t('standalone.dashboard.active') }}
+
+
+
+ {{ t('standalone.dashboard.inactive') }}
+
diff --git a/src/components/standalone/dashboard/OpenVpnTunnelOrIpsecCard.vue b/src/components/standalone/dashboard/OpenVpnTunnelOrIpsecCard.vue
index 5943670f5..f00bb5150 100644
--- a/src/components/standalone/dashboard/OpenVpnTunnelOrIpsecCard.vue
+++ b/src/components/standalone/dashboard/OpenVpnTunnelOrIpsecCard.vue
@@ -4,97 +4,51 @@
-->
-
-
-
- {{ counters.enabled }}
- {{
- t('standalone.dashboard.tunnels_enabled', counters.enabled)
- }}
-
-
- {{ counters.connected }}
- {{
- t('standalone.dashboard.tunnels_connected', counters.connected)
- }}
-
+
+ {{ counters.enabled }}
+ {{ t('standalone.dashboard.tunnels_enabled', counters.enabled) }}
+
+
+ {{ counters.connected }}
+ {{
+ t('standalone.dashboard.tunnels_connected', counters.connected)
+ }}
diff --git a/src/components/standalone/dashboard/ServiceCard.vue b/src/components/standalone/dashboard/ServiceCard.vue
index 84345fa06..3dc5e0f59 100644
--- a/src/components/standalone/dashboard/ServiceCard.vue
+++ b/src/components/standalone/dashboard/ServiceCard.vue
@@ -4,152 +4,44 @@
-->
@@ -157,28 +49,23 @@ function getBadgeIcon(status: string) {
:title="title"
:icon="icon"
:skeleton-lines="2"
- :loading="loading.getServiceStatus || loading.getServiceCounter"
- :error-title="error.title"
- :error-description="error.description"
+ :loading="isPending"
+ :error-title="errorTitle"
+ :error-description="errorDescription"
>
-
-
-
-
- {{ serviceCounter }}
- {{ counter.label }}
-
-
+
+
+ {{ t(badge.textKey) }}
+
+
+ {{ serviceCounter ?? '-' }}
+ {{ counter.label }}
+
diff --git a/src/components/standalone/dashboard/SystemInfoCard.vue b/src/components/standalone/dashboard/SystemInfoCard.vue
index c94965d67..76b785252 100644
--- a/src/components/standalone/dashboard/SystemInfoCard.vue
+++ b/src/components/standalone/dashboard/SystemInfoCard.vue
@@ -15,49 +15,83 @@ import {
byteFormat1024,
NeSpinner
} from '@nethesis/vue-components'
-import { computed, onMounted, onUnmounted, ref } from 'vue'
+import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { round } from 'lodash-es'
import { getStandaloneRoutePrefix } from '@/lib/router'
import { useRouter } from 'vue-router'
import type { SystemUpdate } from '@/views/standalone/system/UpdateView.vue'
+import { useQuery } from '@tanstack/vue-query'
+import { faServer, faWarning } from '@fortawesome/free-solid-svg-icons'
+import { useDashboardOverview, type StorageEntry } from '@/composables/useDashboardOverview'
const { t } = useI18n()
const router = useRouter()
-// random refresh interval between 20 and 30 seconds
-const REFRESH_INTERVAL = 20000 + Math.random() * 10 * 1000
-const systemInfo = ref
({})
-const loadDataIntervalId = ref(0)
-const systemUpdateData = ref(null)
+type SystemUpdateResponse = {
+ data: SystemUpdate
+}
-const freeMemory = ref(0)
-const totalMemory = ref(0)
-const memoryUsagePerc = ref(0)
+type Usage = {
+ free: number
+ total: number
+ perc: number
+}
-const freeRoot = ref(0)
-const totalRoot = ref(0)
-const rootUsagePerc = ref(0)
+// check-system-update queries the remote update server: poll it sparingly
+const UPDATE_CHECK_INTERVAL = 30 * 60 * 1000
-const freeTmpfs = ref(0)
-const totalTmpfs = ref(0)
-const tmpfsUsagePerc = ref(0)
+const {
+ data: overview,
+ isPending: isSystemInfoPending,
+ isError: isSystemInfoError,
+ error: systemInfoError
+} = useDashboardOverview()
-const freeDataStorage = ref(0)
-const totalDataStorage = ref(0)
-const dataStorageUsagePerc = ref(0)
+const systemInfo = computed(() => overview.value?.system ?? null)
-const loading = ref({
- getSystemInfo: true,
- getUpdatesStatus: true
+const { data: systemUpdateData, isFetching: isUpdateStatusFetching } = useQuery({
+ queryKey: ['dashboard', 'check-system-update'],
+ queryFn: ({ signal }) =>
+ ubusCall('ns.update', 'check-system-update', {}, { signal }),
+ select: (res) => res.data,
+ refetchInterval: UPDATE_CHECK_INTERVAL,
+ staleTime: UPDATE_CHECK_INTERVAL,
+ // run only after system-info has returned data
+ enabled: computed(() => !!systemInfo.value)
})
-const error = ref({
- title: '',
- description: ''
+const errorTitle = computed(() =>
+ isSystemInfoError.value ? t('error.cannot_retrieve_system_info') : ''
+)
+const errorDescription = computed(() =>
+ isSystemInfoError.value ? t(getAxiosErrorMessage(systemInfoError.value)) : ''
+)
+
+function usageFromStorage(entry?: StorageEntry): Usage {
+ const free = entry?.available_bytes ?? 0
+ const used = entry?.used_bytes ?? 0
+ const total = free + used
+ return { free, total, perc: total ? round((used / total) * 100) : 0 }
+}
+
+const memory = computed(() => {
+ const mem = systemInfo.value?.memory
+ if (!mem) {
+ return { free: 0, total: 0, perc: 0 }
+ }
+
+ const total = mem.mem_total
+ const free = mem.mem_available
+ const used = total - free
+ return { free, total, perc: total ? round((used / total) * 100) : 0 }
})
+const root = computed(() => usageFromStorage(systemInfo.value?.storage['/']))
+const tmpfs = computed(() => usageFromStorage(systemInfo.value?.storage['tmpfs']))
+const dataStorage = computed(() => usageFromStorage(systemInfo.value?.storage['/mnt/data']))
+
const isUpdateAvailable = computed(
() =>
systemUpdateData.value?.lastVersion &&
@@ -68,72 +102,6 @@ const isUpdateScheduled = computed(
() => systemUpdateData.value?.scheduledAt && systemUpdateData.value.scheduledAt != -1
)
-onMounted(() => {
- loadData()
-
- // periodically reload system info
- loadDataIntervalId.value = setInterval(loadData, REFRESH_INTERVAL)
-})
-
-onUnmounted(() => {
- if (loadDataIntervalId.value) {
- clearInterval(loadDataIntervalId.value)
- }
-})
-
-function loadData() {
- error.value.title = ''
- error.value.description = ''
- getSystemInfo()
- getUpdatesStatus()
-}
-
-async function getSystemInfo() {
- try {
- const res = await ubusCall('ns.dashboard', 'system-info')
- systemInfo.value = res.data.result
-
- // Support both old format (available_bytes, used_bytes) and new format (mem_total, mem_available)
- // Introduced by https://github.com/NethServer/nethsecurity/pull/1569, can be removed after the next release since controllers should be updated
- if (
- systemInfo.value.memory.mem_total != undefined &&
- systemInfo.value.memory.mem_available != undefined
- ) {
- // New format
- totalMemory.value = systemInfo.value.memory.mem_total
- freeMemory.value = systemInfo.value.memory.mem_available
- } else {
- // Old format
- freeMemory.value = systemInfo.value.memory.available_bytes
- const usedMemory = systemInfo.value.memory.used_bytes
- totalMemory.value = usedMemory + freeMemory.value
- }
- const usedMemory = totalMemory.value - freeMemory.value
- memoryUsagePerc.value = round((usedMemory / totalMemory.value) * 100)
-
- freeRoot.value = systemInfo.value.storage['/'].available_bytes
- const usedRoot = systemInfo.value.storage['/'].used_bytes
- totalRoot.value = usedRoot + freeRoot.value
- rootUsagePerc.value = round((usedRoot / totalRoot.value) * 100)
-
- freeTmpfs.value = systemInfo.value.storage['tmpfs'].available_bytes
- const usedTmpfs = systemInfo.value.storage['tmpfs'].used_bytes
- totalTmpfs.value = usedTmpfs + freeTmpfs.value
- tmpfsUsagePerc.value = round((usedTmpfs / totalTmpfs.value) * 100)
-
- freeDataStorage.value = systemInfo.value.storage['/mnt/data'].available_bytes
- const usedDataStorage = systemInfo.value.storage['/mnt/data'].used_bytes
- totalDataStorage.value = usedDataStorage + freeDataStorage.value
- dataStorageUsagePerc.value = round((usedDataStorage / totalDataStorage.value) * 100)
- } catch (err: any) {
- console.error(err)
- error.value.title = t('error.cannot_retrieve_system_info')
- error.value.description = t(getAxiosErrorMessage(err))
- } finally {
- loading.value.getSystemInfo = false
- }
-}
-
function getProgressBarColor(progress: number) {
if (progress < 75) {
return 'primary'
@@ -151,30 +119,21 @@ function goToSystemSettings() {
function goToUpdates() {
router.push(`${getStandaloneRoutePrefix()}/system/update`)
}
-
-async function getUpdatesStatus() {
- try {
- systemUpdateData.value = (await ubusCall('ns.update', 'check-system-update')).data
- // Ignore error: toast error notification is enough here
- } finally {
- loading.value.getUpdatesStatus = false
- }
-}
-
+
{{ systemInfo?.hardware || '-' }}
@@ -184,10 +143,10 @@ async function getUpdatesStatus() {
{{ systemInfo?.hostname || '-' }}
-
+
-
@@ -212,12 +171,12 @@ async function getUpdatesStatus() {
{{ t('standalone.dashboard.operating_system') }}
{{ systemInfo?.version?.release || '-' }}
-
+
-
@@ -251,14 +210,14 @@ async function getUpdatesStatus() {
{{ t('standalone.dashboard.memory_usage') }}
{{
t('standalone.dashboard.usage_free_of_total', {
- free: byteFormat1024(freeMemory),
- total: byteFormat1024(totalMemory)
+ free: byteFormat1024(memory.free),
+ total: byteFormat1024(memory.total)
})
}}
@@ -266,14 +225,14 @@ async function getUpdatesStatus() {
{{ t('standalone.dashboard.root_usage') }}
{{
t('standalone.dashboard.usage_free_of_total', {
- free: byteFormat1024(freeRoot),
- total: byteFormat1024(totalRoot)
+ free: byteFormat1024(root.free),
+ total: byteFormat1024(root.total)
})
}}
@@ -281,29 +240,29 @@ async function getUpdatesStatus() {
{{ t('standalone.dashboard.tmpfs_usage') }}
{{
t('standalone.dashboard.usage_free_of_total', {
- free: byteFormat1024(freeTmpfs),
- total: byteFormat1024(totalTmpfs)
+ free: byteFormat1024(tmpfs.free),
+ total: byteFormat1024(tmpfs.total)
})
}}
-
+
{{ t('standalone.dashboard.storage_usage') }}
{{
t('standalone.dashboard.usage_free_of_total', {
- free: byteFormat1024(freeDataStorage),
- total: byteFormat1024(totalDataStorage)
+ free: byteFormat1024(dataStorage.free),
+ total: byteFormat1024(dataStorage.total)
})
}}
diff --git a/src/components/standalone/dashboard/ThreatShieldIpCard.vue b/src/components/standalone/dashboard/ThreatShieldIpCard.vue
index e3059d63c..52bbcb05c 100644
--- a/src/components/standalone/dashboard/ThreatShieldIpCard.vue
+++ b/src/components/standalone/dashboard/ThreatShieldIpCard.vue
@@ -4,163 +4,61 @@
-->
diff --git a/src/components/standalone/monitoring/SecurityMonitor.vue b/src/components/standalone/monitoring/SecurityMonitor.vue
index c069c29cd..8bd028ea6 100644
--- a/src/components/standalone/monitoring/SecurityMonitor.vue
+++ b/src/components/standalone/monitoring/SecurityMonitor.vue
@@ -91,6 +91,8 @@ async function getThreatShieldSettings() {
error.value.getThreatShieldSettingsDetails = ''
try {
+ // @deprecated fetch via useThreatShieldSettings() composable
+ // (@/composables/useThreatShieldSettings) — adds abort-on-unmount signaling
const res = await ubusCall('ns.threatshield', 'list-settings')
isThreatShieldEnabled.value = res.data.data.enabled
diff --git a/src/components/standalone/security/threat_shield/BlocklistTab.vue b/src/components/standalone/security/threat_shield/BlocklistTab.vue
index 87f010375..20b9b0964 100644
--- a/src/components/standalone/security/threat_shield/BlocklistTab.vue
+++ b/src/components/standalone/security/threat_shield/BlocklistTab.vue
@@ -78,6 +78,8 @@ async function fetchBlocklists() {
try {
loading.value = true
blocklists.value = (await ubusCall('ns.threatshield', 'list-blocklist')).data.data
+ // @deprecated fetch via useThreatShieldSettings() composable
+ // (@/composables/useThreatShieldSettings) — adds abort-on-unmount signaling
isThreatShieldEnabled.value = (
await ubusCall('ns.threatshield', 'list-settings')
).data.data.enabled
diff --git a/src/components/standalone/security/threat_shield/GeoBlockingTab.vue b/src/components/standalone/security/threat_shield/GeoBlockingTab.vue
index 77d7aedfd..edf4ba536 100644
--- a/src/components/standalone/security/threat_shield/GeoBlockingTab.vue
+++ b/src/components/standalone/security/threat_shield/GeoBlockingTab.vue
@@ -99,6 +99,8 @@ const {
error: settingsError
} = useQuery({
queryKey: ['threatshield', 'settings'],
+ // @deprecated fetch via useThreatShieldSettings() composable
+ // (@/composables/useThreatShieldSettings) — adds abort-on-unmount signaling
queryFn: () =>
ubusCall<{ data: { data: { enabled: boolean } } }>('ns.threatshield', 'list-settings'),
select: (res) => res.data.data
diff --git a/src/components/standalone/security/threat_shield/SettingsTab.vue b/src/components/standalone/security/threat_shield/SettingsTab.vue
index 7fa8333fe..a7bcc1a29 100644
--- a/src/components/standalone/security/threat_shield/SettingsTab.vue
+++ b/src/components/standalone/security/threat_shield/SettingsTab.vue
@@ -98,6 +98,8 @@ type ListSettingsResponse = {
async function fetchSettings() {
try {
loading.value.listSettings = true
+ // @deprecated fetch via useThreatShieldSettings() composable
+ // (@/composables/useThreatShieldSettings) — adds abort-on-unmount signaling
const res = await ubusCall('ns.threatshield', 'list-settings')
const threatShieldConfig = res.data.data
isThreatShieldEnabled.value = threatShieldConfig.enabled
diff --git a/src/composables/useAlerts.ts b/src/composables/useAlerts.ts
index ed059b39d..016e1ec85 100644
--- a/src/composables/useAlerts.ts
+++ b/src/composables/useAlerts.ts
@@ -1,6 +1,5 @@
import { ubusCall } from '@/lib/standalone/ubus'
import { useQuery } from '@tanstack/vue-query'
-import type { AxiosResponse } from 'axios'
import type { NeNotificationV2 } from '@nethesis/vue-components'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -17,7 +16,9 @@ export type Alert = {
}
type ListAlertsResponse = {
- alerts: Alert[]
+ data: {
+ alerts: Alert[]
+ }
}
function getSeverityBadgeKind(severity: string | undefined): NeNotificationV2['kind'] {
@@ -37,8 +38,8 @@ export function useAlerts() {
const { data, error, status, isPending, isError, dataUpdatedAt } = useQuery({
queryKey: ['metrics', 'alerts'],
- queryFn: async () =>
- await ubusCall>('ns.telegraf', 'list-alerts'),
+ queryFn: ({ signal }) =>
+ ubusCall('ns.telegraf', 'list-alerts', {}, { signal }),
select: (response) => response.data.alerts,
refetchInterval: 15000,
// Pause polling while a disruptive system action (reboot, update, image
diff --git a/src/composables/useDashboardOverview.ts b/src/composables/useDashboardOverview.ts
new file mode 100644
index 000000000..1142b6489
--- /dev/null
+++ b/src/composables/useDashboardOverview.ts
@@ -0,0 +1,87 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { ubusCall } from '@/lib/standalone/ubus'
+import { useQuery } from '@tanstack/vue-query'
+
+export type ServiceStatus = 'ok' | 'warning' | 'error' | 'disabled' | null
+
+export type StorageEntry = {
+ used_bytes: number
+ available_bytes: number
+}
+
+export type SystemInfo = {
+ uptime: number
+ load: [number, number, number]
+ version: {
+ arch: string
+ release: string
+ }
+ hostname: string
+ hardware: string
+ memory: {
+ mem_total: number
+ mem_available: number
+ }
+ storage: {
+ '/': StorageEntry
+ '/mnt/data': StorageEntry
+ tmpfs: StorageEntry
+ }
+}
+
+export type TunnelCounters = {
+ enabled: number
+ connected: number
+}
+
+// Every section can degrade to null if the corresponding subsystem fails on
+// the firewall; the section name is then listed in `errors`.
+export type DashboardOverview = {
+ system: SystemInfo | null
+ services: {
+ dns_configured: ServiceStatus
+ mwan: ServiceStatus
+ openvpn_rw: ServiceStatus
+ threat_shield_dns: ServiceStatus
+ dedalo: ServiceStatus
+ banip: ServiceStatus
+ }
+ counters: {
+ hosts: number | null
+ openvpn_rw: number | null
+ threat_shield_ip: number | null
+ }
+ vpn: {
+ ipsec: TunnelCounters | null
+ ovpn: TunnelCounters | null
+ }
+ errors: string[]
+}
+
+export type DashboardServiceName = keyof DashboardOverview['services']
+export type DashboardCounterName = keyof DashboardOverview['counters']
+
+// ubusCall returns the HTTP body (it already unwraps the AxiosResponse). The
+// api-server wraps every ubus result under `data`, and the ns.dashboard script
+// wraps its output under `result`.
+type DashboardOverviewResponse = {
+ data: {
+ result: DashboardOverview
+ }
+}
+
+export const DASHBOARD_REFRESH_INTERVAL = 20_000
+
+export function useDashboardOverview() {
+ // The same query key is shared by all dashboard cards: TanStack Query
+ // deduplicates them into a single HTTP request per refresh interval
+ return useQuery({
+ queryKey: ['dashboard', 'overview'],
+ queryFn: ({ signal }) =>
+ ubusCall('ns.dashboard', 'dashboard-v2', {}, { signal }),
+ select: (res) => res.data.result,
+ refetchInterval: DASHBOARD_REFRESH_INTERVAL
+ })
+}
diff --git a/src/composables/useIpsStatus.ts b/src/composables/useIpsStatus.ts
new file mode 100644
index 000000000..a2d40bf6f
--- /dev/null
+++ b/src/composables/useIpsStatus.ts
@@ -0,0 +1,30 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { ubusCall } from '@/lib/standalone/ubus'
+import { useQuery } from '@tanstack/vue-query'
+
+export type IpsStatus = {
+ enabled: boolean
+ events: number
+}
+
+// ubusCall unwraps the AxiosResponse; the api-server wraps the ubus result
+// under `data`.
+type IpsStatusResponse = {
+ data: {
+ status: IpsStatus
+ }
+}
+
+// Shared query for the IPS (snort) status. The `signal` from the queryFn
+// context is threaded into ubusCall so the request is aborted when the last
+// consuming component unmounts.
+export function useIpsStatus(options?: { refetchInterval?: number }) {
+ return useQuery({
+ queryKey: ['ips', 'status'],
+ queryFn: ({ signal }) => ubusCall('ns.snort', 'status', {}, { signal }),
+ select: (res) => res.data.status,
+ refetchInterval: options?.refetchInterval
+ })
+}
diff --git a/src/composables/useThreatShieldSettings.ts b/src/composables/useThreatShieldSettings.ts
new file mode 100644
index 000000000..53b0700ff
--- /dev/null
+++ b/src/composables/useThreatShieldSettings.ts
@@ -0,0 +1,36 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { ubusCall } from '@/lib/standalone/ubus'
+import { useQuery } from '@tanstack/vue-query'
+
+// Different consumers read different fields of the ThreatShield IP
+// list-settings payload, so all are optional here.
+export type ThreatShieldSettings = {
+ enabled?: boolean
+ ban_logforwardlan?: boolean
+ ban_logforwardwan?: boolean
+ ban_loginput?: boolean
+ ban_logprerouting?: boolean
+}
+
+// api-server wraps the ubus result under `data`, and ns.threatshield wraps its
+// own output under a second `data`.
+type ThreatShieldSettingsResponse = {
+ data: {
+ data: ThreatShieldSettings
+ }
+}
+
+// Shared query for ThreatShield IP settings (ns.threatshield list-settings).
+// The `signal` from the queryFn context is threaded into ubusCall so the
+// request is aborted when the last consuming component unmounts.
+export function useThreatShieldSettings(options?: { refetchInterval?: number }) {
+ return useQuery({
+ queryKey: ['threatshield', 'settings'],
+ queryFn: ({ signal }) =>
+ ubusCall('ns.threatshield', 'list-settings', {}, { signal }),
+ select: (res) => res.data.data,
+ refetchInterval: options?.refetchInterval
+ })
+}
diff --git a/src/lib/__tests__/dashboard.spec.ts b/src/lib/__tests__/dashboard.spec.ts
new file mode 100644
index 000000000..9572c2c2b
--- /dev/null
+++ b/src/lib/__tests__/dashboard.spec.ts
@@ -0,0 +1,56 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest'
+import { getStatusBadge } from '@/lib/standalone/dashboard'
+import { faCheck, faWarning, faXmark } from '@fortawesome/free-solid-svg-icons'
+
+describe('getStatusBadge', () => {
+ it('maps ok to a green badge', () => {
+ expect(getStatusBadge('ok')).toEqual({
+ kind: 'green',
+ icon: faCheck,
+ textKey: 'standalone.dashboard.active'
+ })
+ })
+
+ it('maps warning to an amber badge', () => {
+ expect(getStatusBadge('warning')).toEqual({
+ kind: 'amber',
+ icon: faWarning,
+ textKey: 'standalone.dashboard.warning'
+ })
+ })
+
+ it('maps disabled to a gray badge', () => {
+ expect(getStatusBadge('disabled')).toEqual({
+ kind: 'gray',
+ icon: faXmark,
+ textKey: 'standalone.dashboard.inactive'
+ })
+ })
+
+ it('maps error to a rose badge with unknown text', () => {
+ expect(getStatusBadge('error')).toEqual({
+ kind: 'rose',
+ icon: faXmark,
+ textKey: 'standalone.dashboard.unknown'
+ })
+ })
+
+ it('maps null (degraded backend section) to a rose badge with unknown text', () => {
+ expect(getStatusBadge(null)).toEqual({
+ kind: 'rose',
+ icon: faXmark,
+ textKey: 'standalone.dashboard.unknown'
+ })
+ })
+
+ it('maps undefined (data not loaded yet) to a rose badge with unknown text', () => {
+ expect(getStatusBadge(undefined)).toEqual({
+ kind: 'rose',
+ icon: faXmark,
+ textKey: 'standalone.dashboard.unknown'
+ })
+ })
+})
diff --git a/src/lib/standalone/dashboard.ts b/src/lib/standalone/dashboard.ts
new file mode 100644
index 000000000..c6f59018d
--- /dev/null
+++ b/src/lib/standalone/dashboard.ts
@@ -0,0 +1,26 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { faCheck, faWarning, faXmark, type IconDefinition } from '@fortawesome/free-solid-svg-icons'
+import type { NeBadgeV2Kind } from '@nethesis/vue-components'
+import type { ServiceStatus } from '@/composables/useDashboardOverview'
+
+export type StatusBadge = {
+ kind: NeBadgeV2Kind
+ icon: IconDefinition
+ // i18n key, to be translated by the consuming component
+ textKey: string
+}
+
+export function getStatusBadge(status: ServiceStatus | undefined): StatusBadge {
+ switch (status) {
+ case 'ok':
+ return { kind: 'green', icon: faCheck, textKey: 'standalone.dashboard.active' }
+ case 'warning':
+ return { kind: 'amber', icon: faWarning, textKey: 'standalone.dashboard.warning' }
+ case 'disabled':
+ return { kind: 'gray', icon: faXmark, textKey: 'standalone.dashboard.inactive' }
+ default:
+ return { kind: 'rose', icon: faXmark, textKey: 'standalone.dashboard.unknown' }
+ }
+}
diff --git a/src/stores/standalone/haStatus.ts b/src/stores/standalone/haStatus.ts
deleted file mode 100644
index e8b07801c..000000000
--- a/src/stores/standalone/haStatus.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { defineStore } from 'pinia'
-import { onMounted, ref } from 'vue'
-import { ubusCall } from '@/lib/standalone/ubus'
-import type { AxiosResponse } from 'axios'
-
-type HaStatus = AxiosResponse<{
- state: string
- role: string
- status: string
- last_sync_status: string
- last_sync_time: number
-}>
-
-export const useHaStatusStore = defineStore('haStatus', () => {
- const state = ref('')
- const role = ref('')
- const status = ref('')
- const lastSyncStatus = ref('')
- const lastSyncTime = ref(0)
-
- const loading = ref(true)
- const error = ref()
-
- function fetchStatus() {
- ubusCall('ns.ha', 'status', {})
- .then((response: HaStatus) => {
- state.value = response.data.state
- role.value = response.data.role
- status.value = response.data.status
- lastSyncStatus.value = response.data.last_sync_status.toLowerCase().replace(/ /g, '_')
- lastSyncTime.value = response.data.last_sync_time
- })
- .catch((reason: Error) => {
- error.value = reason
- })
- .finally(() => {
- loading.value = false
- })
- }
-
- onMounted(() => {
- fetchStatus()
- })
-
- return {
- state,
- role,
- status,
- lastSyncStatus,
- lastSyncTime,
- loading,
- error,
- fetchStatus
- }
-})
diff --git a/src/stores/standalone/ipsStatus.ts b/src/stores/standalone/ipsStatus.ts
index cd343ca98..a00514c3d 100644
--- a/src/stores/standalone/ipsStatus.ts
+++ b/src/stores/standalone/ipsStatus.ts
@@ -10,6 +10,13 @@ type IpsStatus = AxiosResponse<{
}
}>
+/**
+ * @deprecated Use the `useIpsStatus()` composable
+ * (`@/composables/useIpsStatus`) instead. It fetches `ns.snort status` via
+ * TanStack Query with an abort signal, so the request is cancelled when the
+ * consuming component unmounts. This store fetches manually on mount without
+ * signaling and is kept only until its consumers are migrated.
+ */
export const useIpsStatusStore = defineStore('ipsStatus', () => {
const enabled = ref(false)
const events = ref(0)
diff --git a/src/views/standalone/StandaloneDashboardView.vue b/src/views/standalone/StandaloneDashboardView.vue
index 2d91d2593..d1f100fa9 100644
--- a/src/views/standalone/StandaloneDashboardView.vue
+++ b/src/views/standalone/StandaloneDashboardView.vue
@@ -4,22 +4,22 @@
-->
-
-
{{ t('standalone.dashboard.title') }}
-
- {{ t('common.data_updated_every_seconds', { seconds: 20 }) }}
+
+
+
{{ t('standalone.dashboard.title') }}
+
+ {{ t('common.data_updated_every_seconds', { seconds: 20 }) }}
+
-
-
-
-
-
-
-
-
-
-
-
- {{ t('standalone.dashboard.multiwan') }}
-
-
-
-
-
-
-
-
-
- {{ t('standalone.dashboard.openvpn_rw') }}
-
-
-
-
-
-
-
- {{ t('standalone.ipsec_tunnel.title') }}
-
-
-
-
-
-
-
- {{ t('standalone.openvpn_tunnel.title') }}
-
-
-
-
-
-
-
- {{ t('standalone.wireguard_tunnel.title') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ t('standalone.threat_shield_dns.title') }}
-
-
-
-
-
-
-
- {{ t('standalone.dashboard.hotspot') }}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {{ t('standalone.dashboard.multiwan') }}
+
+
+
+
+
+
+
+
+
+ {{ t('standalone.dashboard.openvpn_rw') }}
+
+
+
+
+
+
+
+ {{ t('standalone.ipsec_tunnel.title') }}
+
+
+
+
+
+
+
+ {{ t('standalone.openvpn_tunnel.title') }}
+
+
+
+
+
+
+
+ {{ t('standalone.wireguard_tunnel.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('standalone.threat_shield_dns.title') }}
+
+
+
+
+
+
+
+ {{ t('standalone.dashboard.hotspot') }}
+
+
+
+
+
+
+
+