From d08c1dc591b4a0467eebb060f79c44cdd973c49c Mon Sep 17 00:00:00 2001
From: Giacomo Sanchietti
Date: Tue, 10 Nov 2026 14:37:46 +0100
Subject: [PATCH 1/4] feat(monitoring): add live trace page
Administrators cannot tell which firewall layer blocks a client.
The Monitoring > Live trace page traces one client IP for a limited
time and shows the blocked connections with layer and rule, the DNS
queries and the live connections with rates.
Uses the new ns.livetrace API.
Assisted-by: Claude Code:claude-fable-5-2
---
src/components/standalone/SideMenu.vue | 4 +
.../monitoring/livetrace/LiveTrace.vue | 1017 +++++++++++++++++
src/i18n/en.json | 71 +-
src/i18n/it.json | 71 +-
src/router/index.ts | 5 +
.../standalone/monitoring/LiveTraceView.vue | 21 +
6 files changed, 1187 insertions(+), 2 deletions(-)
create mode 100644 src/components/standalone/monitoring/livetrace/LiveTrace.vue
create mode 100644 src/views/standalone/monitoring/LiveTraceView.vue
diff --git a/src/components/standalone/SideMenu.vue b/src/components/standalone/SideMenu.vue
index f565fb5ff..6d8b4981a 100644
--- a/src/components/standalone/SideMenu.vue
+++ b/src/components/standalone/SideMenu.vue
@@ -44,6 +44,10 @@ const navigation: Ref
-
- {{ item.count }}
-
@@ -1012,6 +955,40 @@ const ruleDetails = (rule: FirewallRule): [string, string][] => [
+
+
+ {{ t('standalone.live_trace.dns_queries') }}
+
+
+
+ {{ t('common.name') }}
+ {{ t('common.type') }}
+ {{ t('standalone.live_trace.result') }}
+ {{ t('standalone.live_trace.count') }}
+
+
+
+
+ {{ item.name }}
+
+
+ {{ item.type }}
+
+
+
+ {{ dnsResult(item.result) }}
+
+ {{ t('standalone.live_trace.blocked_by_dns_filter') }}
+
+
+
+
+ {{ item.count }}
+
+
+
+
+
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 4d44d1a59..35543463e 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -2735,8 +2735,7 @@
"dns_not_found": "Domain not found",
"dns_server_failure": "Server failure",
"dns_refused": "Refused",
- "go_to_rules": "Go to Firewall rules",
- "show_details": "Show details"
+ "go_to_rules": "Go to Firewall rules"
},
"flows": {
"subtitle": "View real-time network connections, each row represents an active flow with live transfer rates and total traffic.",
diff --git a/src/i18n/it.json b/src/i18n/it.json
index e9907a3d7..0d0d5584e 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -3191,8 +3191,7 @@
"dns_not_found": "Dominio non trovato",
"dns_server_failure": "Errore del server",
"dns_refused": "Rifiutata",
- "go_to_rules": "Vai a Regole firewall",
- "show_details": "Mostra dettagli"
+ "go_to_rules": "Vai a Regole firewall"
}
},
"controller": {
From 7bd4f5d5b7573824142df9724e6f09ed3e0da6ac Mon Sep 17 00:00:00 2001
From: Giacomo Sanchietti
Date: Thu, 12 Nov 2026 16:51:18 +0100
Subject: [PATCH 3/4] fix(monitoring): align live trace with standards
Make the Go to Firewall rules link reachable from the keyboard, and
reword the page description and empty states by the Nebula writing
guidelines.
Validate the IP address and port with valibot and the shared
validators, clear a stale start error when stopping, type the ubus
calls, and use hierarchical query keys and full i18n keys.
Assisted-by: Claude Code:Opus 5.5
---
.../monitoring/livetrace/LiveTrace.vue | 132 ++++++++++++------
src/i18n/en.json | 9 +-
src/i18n/it.json | 9 +-
3 files changed, 99 insertions(+), 51 deletions(-)
diff --git a/src/components/standalone/monitoring/livetrace/LiveTrace.vue b/src/components/standalone/monitoring/livetrace/LiveTrace.vue
index 789ed998c..ce67babc1 100644
--- a/src/components/standalone/monitoring/livetrace/LiveTrace.vue
+++ b/src/components/standalone/monitoring/livetrace/LiveTrace.vue
@@ -7,6 +7,8 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { isAxiosError } from 'axios'
+import * as v from 'valibot'
import {
byteFormat1024,
getAxiosErrorMessage,
@@ -34,9 +36,8 @@ import {
} from '@nethesis/vue-components'
import { faBan, faShieldHalved, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
-import type { AxiosError } from 'axios'
import { ubusCall, ValidationError } from '@/lib/standalone/ubus'
-import { MessageBag } from '@/lib/validation'
+import { MessageBag, validateIpAddress, validatePort } from '@/lib/validation'
import { formatRate } from '@/composables/useFlows'
import { getStandaloneRoutePrefix } from '@/lib/router'
import type { FirewallRule, RuleHost } from '@/stores/standalone/firewall'
@@ -142,7 +143,7 @@ const layerLabels: Record = {
}
const ip = ref('')
-const duration = ref(durationOptions[1]!.id)
+const duration = ref('120')
const port = ref('')
const protocol = ref('any')
const filter = ref('')
@@ -153,23 +154,28 @@ const destinationFilter = ref([])
const statusFilter = ref([])
const validationBag = ref(new MessageBag())
+const STATUS_KEY = ['livetrace', 'status']
+
// poll every 2s while running, the poll that sees running=false is the final status
const { data, error, isPending } = useQuery({
- queryKey: ['livetrace', 'status'],
- queryFn: async () => (await ubusCall<{ data: Status }>('ns.livetrace', 'status')).data,
- refetchInterval: (query) => (query.state.data?.running ? 2000 : false)
+ queryKey: STATUS_KEY,
+ queryFn: () => ubusCall<{ data: Status }>('ns.livetrace', 'status'),
+ select: (res) => res.data,
+ refetchInterval: (query) => (query.state.data?.data.running ? 2000 : false)
})
-const refreshStatus = () => queryClient.invalidateQueries({ queryKey: ['livetrace', 'status'] })
+const refreshStatus = () => queryClient.invalidateQueries({ queryKey: STATUS_KEY })
const start = useMutation({
- mutationFn: () =>
- ubusCall('ns.livetrace', 'start', {
- ip: ip.value,
+ mutationFn: () => {
+ const portValue = String(port.value).trim()
+ return ubusCall<{ data: unknown }>('ns.livetrace', 'start', {
+ ip: ip.value.trim(),
duration: Number(duration.value),
- ...(String(port.value).trim() ? { port: Number(port.value) } : {}),
+ ...(portValue ? { port: Number(portValue) } : {}),
...(protocol.value != 'any' ? { protocol: protocol.value } : {})
- }),
+ })
+ },
onMutate: () => {
validationBag.value.clear()
stop.reset()
@@ -183,37 +189,64 @@ const start = useMutation({
}
})
-// the backend treats port 0 as no filter, reject it here
-function startTrace() {
+const stop = useMutation({
+ mutationFn: () => ubusCall<{ data: unknown }>('ns.livetrace', 'stop'),
+ onMutate: () => start.reset(),
+ onSuccess: refreshStatus
+})
+
+// the backend treats port 0 as no filter, an empty port means no filter
+const formSchema = v.object({
+ ip: v.pipe(
+ v.string(),
+ v.check((value) => validateIpAddress(value.trim()).valid, 'error.invalid_ip_address')
+ ),
+ port: v.pipe(
+ v.string(),
+ v.check(
+ (value) => value.trim() == '' || (/^\d+$/.test(value.trim()) && validatePort(value).valid),
+ 'error.invalid_port'
+ )
+ )
+})
+
+function validate(): boolean {
validationBag.value.clear()
- const value = String(port.value).trim()
- const number = Number(value)
- if (value && !(Number.isInteger(number) && number >= 1 && number <= 65535)) {
- validationBag.value.set('port', 'error.invalid_port')
- return
+ const result = v.safeParse(formSchema, { ip: ip.value, port: String(port.value) })
+ if (!result.success) {
+ for (const issue of result.issues) {
+ const field = issue.path?.[0]?.key
+ if (typeof field == 'string' && !validationBag.value.has(field)) {
+ validationBag.value.set(field, issue.message)
+ }
+ }
}
- start.mutate()
+ return result.success
}
-// drop the stale port error once the value changes
-watch(port, () => validationBag.value.delete('port'))
+function startTrace() {
+ if (validate()) {
+ start.mutate()
+ }
+}
-const stop = useMutation({
- mutationFn: () => ubusCall('ns.livetrace', 'stop'),
- onSuccess: refreshStatus
-})
+// drop stale errors once the value changes
+watch(ip, () => validationBag.value.delete('ip'))
+watch(port, () => validationBag.value.delete('port'))
const actionError = computed(() =>
start.error.value instanceof ValidationError ? null : (start.error.value ?? stop.error.value)
)
// start error codes with their own message
+const errorKeys: Record = {
+ session_busy: 'error.session_busy',
+ collector_not_started: 'error.collector_not_started'
+}
const errorDescription = computed(() => {
- const e = (error.value ?? actionError.value) as AxiosError<{ message?: string }> | null
- const code = e?.response?.data?.message ?? ''
- return ['session_busy', 'collector_not_started'].includes(code)
- ? t(`error.${code}`)
- : t(getAxiosErrorMessage(e))
+ const e = error.value ?? actionError.value
+ const code = isAxiosError<{ message?: string }>(e) ? (e.response?.data?.message ?? '') : ''
+ return t(errorKeys[code] ?? getAxiosErrorMessage(e))
})
const hasResults = computed(
@@ -495,7 +528,7 @@ const isUserRule = (item: Blocked) =>
!/^(?:drop|reject)_(?:from|to)_/.test(item.chain ?? '')
const { data: firewallRules } = useQuery({
- queryKey: ['livetrace-firewall-rules'],
+ queryKey: ['livetrace', 'firewall-rules'],
queryFn: async () => {
const responses = await Promise.all(
['list-forward-rules', 'list-input-rules', 'list-output-rules'].map((method) =>
@@ -718,14 +751,20 @@ const ruleDetails = (rule: FirewallRule): [string, string][] => [
{{ value }}
-
- {{ t('standalone.live_trace.go_to_rules') }}
-
+
+ {{ t('standalone.live_trace.go_to_rules') }}
+
+
{{ blockedRule(item) }}
@@ -814,9 +853,9 @@ const ruleDetails = (rule: FirewallRule): [string, string][] => [
@@ -958,7 +997,14 @@ const ruleDetails = (rule: FirewallRule): [string, string][] => [
{{ t('standalone.live_trace.dns_queries') }}
-
+ {{ t('common.name') }}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 35543463e..f3bc80544 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -2674,7 +2674,7 @@
},
"live_trace": {
"title": "Live trace",
- "description": "Trace the traffic of a single client for a limited time. Start a session, ask the user to retry the connection, then check which part of the firewall blocks the traffic.",
+ "description": "Trace the traffic of a single client for a limited time. Start a session, ask the user to retry the connection, then check what blocks the traffic.",
"client_ip": "Client IP address",
"port_n": "port {port}",
"filter_helper": "Ping is not traced when port or protocol is set",
@@ -2693,7 +2693,7 @@
"blocked_traffic": "Blocked traffic",
"blocked_detected": "{n} blocked connection detected | {n} blocked connections detected",
"no_blocked_traffic": "No blocked traffic yet",
- "no_blocked_traffic_description": "Ask the user to retry the connection",
+ "no_blocked_traffic_description": "Ask the user to retry the connection.",
"no_blocked_traffic_last_session": "No blocked traffic during the last session",
"last_seen": "Last seen",
"protocol": "Protocol",
@@ -2713,14 +2713,15 @@
"private_to_wan": "Private destination sent to WAN: missing route or tunnel down",
"default_policy": "Default firewall policy: the interface is not in any zone",
"dns_queries": "DNS queries",
- "no_dns": "No DNS queries",
+ "no_dns": "No DNS queries yet",
+ "no_dns_last_session": "No DNS queries during the last session",
"result": "Result",
"count": "Count",
"blocked_by_dns_filter": "Blocked by DNS filter",
"connections": "Connections",
"allowed_connections": "Allowed connections",
"application": "Application",
- "no_connections": "No connections",
+ "no_connections": "No connections yet",
"no_connections_found": "No connections found",
"filter_connections": "Filter connections",
"download_rate": "Download rate",
diff --git a/src/i18n/it.json b/src/i18n/it.json
index 0d0d5584e..523b1f2c8 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -3130,7 +3130,7 @@
},
"live_trace": {
"title": "Traccia live",
- "description": "Traccia il traffico di un singolo client per un tempo limitato. Avvia una sessione, chiedi all'utente di riprovare la connessione, poi verifica quale parte del firewall blocca il traffico.",
+ "description": "Traccia il traffico di un singolo client per un tempo limitato. Avvia una sessione, chiedi all'utente di riprovare la connessione, poi verifica cosa blocca il traffico.",
"client_ip": "Indirizzo IP del client",
"port_n": "porta {port}",
"filter_helper": "Il ping non viene tracciato se porta o protocollo sono impostati",
@@ -3149,7 +3149,7 @@
"blocked_traffic": "Traffico bloccato",
"blocked_detected": "{n} connessione bloccata rilevata | {n} connessioni bloccate rilevate",
"no_blocked_traffic": "Nessun traffico bloccato finora",
- "no_blocked_traffic_description": "Chiedi all'utente di riprovare la connessione",
+ "no_blocked_traffic_description": "Chiedi all'utente di riprovare la connessione.",
"no_blocked_traffic_last_session": "Nessun traffico bloccato durante l'ultima sessione",
"last_seen": "Ultima occorrenza",
"protocol": "Protocollo",
@@ -3169,14 +3169,15 @@
"private_to_wan": "Destinazione privata inviata alla WAN: rotta mancante o tunnel non attivo",
"default_policy": "Policy predefinita del firewall: l'interfaccia non appartiene ad alcuna zona",
"dns_queries": "Query DNS",
- "no_dns": "Nessuna query DNS",
+ "no_dns": "Nessuna query DNS finora",
+ "no_dns_last_session": "Nessuna query DNS durante l'ultima sessione",
"result": "Risultato",
"count": "Conteggio",
"blocked_by_dns_filter": "Bloccato dal filtro DNS",
"connections": "Connessioni",
"allowed_connections": "Connessioni consentite",
"application": "Applicazione",
- "no_connections": "Nessuna connessione",
+ "no_connections": "Nessuna connessione finora",
"no_connections_found": "Nessuna connessione trovata",
"filter_connections": "Filtra connessioni",
"download_rate": "Velocità download",
From b848c032ae605b4453e25330a8a9efd7b6dfcb0a Mon Sep 17 00:00:00 2001
From: Giacomo Sanchietti
Date: Thu, 12 Nov 2026 17:04:38 +0100
Subject: [PATCH 4/4] fix(monitoring): open live trace rule details with
keyboard
The rule tooltip listened for focus on its wrapper, so focusing the
rule name never opened it and the Go to Firewall rules link was out
of reach. A button that opens it on hover or click works with Enter.
Assisted-by: Claude Code:claude-opus-5-5
---
.../standalone/monitoring/livetrace/LiveTrace.vue | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/components/standalone/monitoring/livetrace/LiveTrace.vue b/src/components/standalone/monitoring/livetrace/LiveTrace.vue
index ce67babc1..b12dab319 100644
--- a/src/components/standalone/monitoring/livetrace/LiveTrace.vue
+++ b/src/components/standalone/monitoring/livetrace/LiveTrace.vue
@@ -735,11 +735,15 @@ const ruleDetails = (rule: FirewallRule): [string, string][] => [
{{ layerLabels[item.layer] ?? item.layer }}