Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -849,11 +849,6 @@
"count": 4
}
},
"src/views/standalone/monitoring/PingLatencyMonitorView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/views/standalone/monitoring/RealTimeMonitoringView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --max-warnings 1229",
"lint": "eslint . --max-warnings 60",
"lint-fix": "npm run lint -- --fix",
"format": "prettier --list-different src/",
"format-fix": "prettier --write src/",
Expand Down
4 changes: 2 additions & 2 deletions src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,15 @@
}

@utility text-primary-neutral {
@apply text-gray-600 dark:text-gray-50;
@apply text-gray-900 dark:text-gray-50;
}

@utility text-secondary {
@apply text-indigo-700 dark:text-indigo-500;
}

@utility text-secondary-neutral {
@apply dark:text-gray-200;
@apply text-gray-700 dark:text-gray-200;
}

@utility text-tertiary-neutral {
Expand Down
9 changes: 7 additions & 2 deletions src/components/charts/TimeLineChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
showLegend?: boolean
useKbpsFormat?: boolean
datasetSuffix?: string
options?: unknown

Check warning on line 36 in src/components/charts/TimeLineChart.vue

View workflow job for this annotation

GitHub Actions / Eslint

Prop 'options' requires default value to be set
}>(),
{ height: '', showLegend: true, useKbpsFormat: false, datasetSuffix: '' }
)
Expand Down Expand Up @@ -125,11 +125,16 @@
}

const allOptions = computed(() => {
return merge(typeof props.options === 'object' ? props.options : {}, defaultOptions)
const customOptions = props.options && typeof props.options === 'object' ? props.options : {}
return merge({}, defaultOptions, customOptions)
})

const chartData: any = computed(() => {
return { labels: props.labels, datasets: props.datasets }
// Deep-clone to strip Vue readonly proxies before handing data to Chart.js.
// Chart.js internally mutates dataset objects (attaches _meta, controllers, etc.)
// and Vue will block those mutations with "target is readonly" warnings if the
// objects are still wrapped in a shallowReadonly prop proxy.
return JSON.parse(JSON.stringify({ labels: props.labels, datasets: props.datasets }))
})

const chartStyle = computed(() => {
Expand Down
4 changes: 4 additions & 0 deletions src/components/standalone/SideMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ const navigation: Ref<MenuItem[]> = ref([
{
name: 'standalone.ping_latency_monitor.title',
to: 'monitoring/ping-latency-monitor'
},
{
name: 'standalone.metrics.title',
to: 'monitoring/metrics'
}
]
},
Expand Down
4 changes: 2 additions & 2 deletions src/components/standalone/firewall/PortForwardTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function getCellClasses(item: PortForward) {
</div>
<div class="flex flex-wrap items-center justify-end gap-4">
<!-- logging info -->
<NeTooltip trigger-event="mouseenter focus" v-if="item.enabled && item.log">
<NeTooltip v-if="item.enabled && item.log" trigger-event="mouseenter focus">
<template #trigger>
<NeLink>
<FontAwesomeIcon
Expand All @@ -147,7 +147,7 @@ function getCellClasses(item: PortForward) {
</template>
</NeTooltip>
<!-- hairpin NAT -->
<NeTooltip trigger-event="mouseenter focus" v-if="item.enabled && item.reflection">
<NeTooltip v-if="item.enabled && item.reflection" trigger-event="mouseenter focus">
<template #trigger>
<NeLink>
<FontAwesomeIcon
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,8 @@ function searchStringInRule(rule: FirewallRule, queryText: string) {
<div class="flex flex-wrap items-center justify-end gap-4">
<!-- logging info -->
<NeTooltip
trigger-event="mouseenter focus"
v-if="isEnabled(rule) && rule.log"
trigger-event="mouseenter focus"
>
<template #trigger>
<NeLink>
Expand All @@ -414,7 +414,7 @@ function searchStringInRule(rule: FirewallRule, queryText: string) {
</template>
</NeTooltip>
<!-- tags -->
<NeTooltip trigger-event="mouseenter focus" v-if="rule.ns_tag.length > 0">
<NeTooltip v-if="rule.ns_tag.length > 0" trigger-event="mouseenter focus">
<template #trigger>
<NeLink>
<FontAwesomeIcon
Expand Down
46 changes: 46 additions & 0 deletions src/components/standalone/monitoring/metrics/MetricChartCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<!--
Copyright (C) 2026 Nethesis S.r.l.
SPDX-License-Identifier: GPL-3.0-or-later
-->

<script lang="ts" setup>
import { faChartLine } from '@fortawesome/free-solid-svg-icons'
import { NeCard, NeEmptyState } from '@nethesis/vue-components'
import { useI18n } from 'vue-i18n'
import TimeLineChart from '@/components/charts/TimeLineChart.vue'
import type { ChartData } from '@/lib/standalone/metricsCharts'

defineProps<{
title: string
chart: ChartData | null
options?: object
showLegend?: boolean
datasetSuffix?: string
useKbpsFormat?: boolean
height?: string
loading?: boolean
}>()

const { t } = useI18n()
</script>

<template>
<NeCard :title="title" :loading="loading">
<TimeLineChart
v-if="chart?.labels?.length"
:labels="chart.labels"
:datasets="chart.datasets"
:height="height ?? '200px'"
:show-legend="showLegend ?? true"
:options="options"
:dataset-suffix="datasetSuffix ?? ''"
:use-kbps-format="useKbpsFormat ?? false"
/>
<NeEmptyState
v-else
:title="t('standalone.metrics.no_data')"
:icon="faChartLine"
class="bg-white dark:bg-gray-950"
/>
</NeCard>
</template>
232 changes: 232 additions & 0 deletions src/components/standalone/monitoring/metrics/MetricsAlertsSection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
<!--
Copyright (C) 2026 Nethesis S.r.l.
SPDX-License-Identifier: GPL-3.0-or-later
-->

<script lang="ts" setup>
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useQuery } from '@tanstack/vue-query'
import {
NeBadgeV2,
type NeBadgeV2Kind,
NeEmptyState,
NeInlineNotification,
NeTable,
NeTableBody,
NeTableCell,
NeTableHead,
NeTableHeadCell,
NeTableRow,
getAxiosErrorMessage
} from '@nethesis/vue-components'
import { ubusCall } from '@/lib/standalone/ubus'
import type { AxiosResponse } from 'axios'
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'

interface MetricsAlert {
activeAt: string
annotations: Record<string, string>
labels: Record<string, string>
name: string
source?: string
state: string
}

interface ListAlertsResponse {
alerts?: MetricsAlert[]
error?: string
}

const { interval } = defineProps<{
interval: number | false
}>()

const emit = defineEmits<{
updatedAt: [number]
}>()

const { t, locale } = useI18n()

const { data, error, isError, isPending, dataUpdatedAt } = useQuery({
queryKey: ['metrics-alerts'],
queryFn: async () => {
const res = await ubusCall<AxiosResponse<ListAlertsResponse>>('ns.telegraf', 'list-alerts')

if (res.data.error) {
throw new Error(res.data.error)
}

return res.data.alerts ?? []
},
refetchInterval: interval,
refetchOnWindowFocus: interval != false
})

watch(
dataUpdatedAt,
(value) => {
emit('updatedAt', value)
},
{ immediate: true }
)

const errorDescription = computed(() => {
const currentError = error.value
const axiosErrorMessage = currentError ? getAxiosErrorMessage(currentError) : undefined

if (axiosErrorMessage) {
return t(axiosErrorMessage)
}

return t('error.generic_error')
})

function getLocalizedAnnotation(alert: MetricsAlert, annotation: 'summary' | 'description') {
const localeCode = locale.value.split('-')[0]

return (
alert.annotations[`${annotation}_${localeCode}`] ??
alert.annotations[`${annotation}_en`] ??
alert.annotations[annotation] ??
''
)
}

function getStateBadgeKind(state: string): NeBadgeV2Kind {
switch (state) {
case 'firing':
return 'rose'
case 'pending':
return 'blue'
default:
return 'gray'
}
}

function getSeverityBadgeKind(severity: string | undefined): NeBadgeV2Kind {
switch (severity) {
case 'critical':
return 'rose'
case 'warning':
return 'amber'
case 'info':
return 'blue'
default:
return 'gray'
}
}

function getSeverityLabel(alert: MetricsAlert) {
switch (alert.labels.severity) {
case 'critical':
return t('standalone.metrics.critical')
case 'warning':
return t('standalone.metrics.warning')
case 'info':
return t('standalone.metrics.info')
default:
return t('standalone.metrics.unknown')
}
}

function getStateLabel(alert: MetricsAlert) {
switch (alert.state) {
case 'firing':
return t('standalone.metrics.firing')
case 'pending':
return t('standalone.metrics.pending')
default:
return t('standalone.metrics.unknown')
}
}

function formatActiveAt(activeAt: string) {
return Intl.DateTimeFormat(locale.value, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(new Date(activeAt))
}
</script>

<template>
<div class="space-y-6">
<NeInlineNotification
v-if="isError"
kind="error"
:title="t('standalone.metrics.cannot_retrieve_alerts')"
:description="errorDescription"
:close-aria-label="t('common.close')"
/>

<NeEmptyState
v-if="!isError && !isPending && !(data ?? []).length"
:title="t('standalone.metrics.no_alerts_yet')"
:description="t('standalone.metrics.no_alerts_description')"
:icon="faCircleCheck"
/>

<NeTable
v-else
:aria-label="t('standalone.metrics.alerts_table_aria_label')"
card-breakpoint="xl"
:loading="isPending"
:skeleton-columns="5"
:skeleton-rows="6"
>
<NeTableHead>
<NeTableHeadCell>{{ t('standalone.metrics.severity') }}</NeTableHeadCell>
<NeTableHeadCell>{{ t('standalone.metrics.alert') }}</NeTableHeadCell>
<NeTableHeadCell>{{ t('standalone.metrics.state') }}</NeTableHeadCell>
<NeTableHeadCell>{{ t('standalone.metrics.triggered_by') }}</NeTableHeadCell>
<NeTableHeadCell>{{ t('standalone.metrics.active_since') }}</NeTableHeadCell>
</NeTableHead>
<NeTableBody>
<NeTableRow v-for="alert in data!" :key="`${alert.name}-${alert.activeAt}`">
<NeTableCell :data-label="t('standalone.metrics.severity')">
<NeBadgeV2 :kind="getSeverityBadgeKind(alert.labels.severity)" size="xs">
{{ getSeverityLabel(alert) }}
</NeBadgeV2>
</NeTableCell>
<NeTableCell :data-label="t('standalone.metrics.alert')">
<div class="space-y-1 whitespace-normal">
<p>
{{ getLocalizedAnnotation(alert, 'summary') || alert.name }}
</p>
<p
v-if="getLocalizedAnnotation(alert, 'description')"
class="text-xs text-tertiary-neutral"
>
{{ getLocalizedAnnotation(alert, 'description') }}
</p>
</div>
</NeTableCell>
<NeTableCell :data-label="t('standalone.metrics.state')">
<NeBadgeV2 :kind="getStateBadgeKind(alert.state)" size="xs">
{{ getStateLabel(alert) }}
</NeBadgeV2>
</NeTableCell>
<NeTableCell :data-label="t('standalone.metrics.triggered_by')">
<div class="space-y-1">
<p>
{{ alert.name }}
</p>
<p class="text-xs text-tertiary-neutral">
{{ t('standalone.metrics.service') }}: {{ alert.labels.service ?? '-' }}
<span class="mx-1">-</span>
{{ t('standalone.metrics.group') }}: {{ alert.labels.alertgroup ?? '-' }}
</p>
</div>
</NeTableCell>
<NeTableCell :data-label="t('standalone.metrics.active_since')">
{{ formatActiveAt(alert.activeAt) }}
</NeTableCell>
</NeTableRow>
</NeTableBody>
</NeTable>
</div>
</template>
Loading
Loading