-
+
{{ stepLabel }} {{ currentStep }}/{{ totalSteps }}
diff --git a/src/components/standalone/dpi/ApplicationGroups.vue b/src/components/standalone/dpi/ApplicationGroups.vue
new file mode 100644
index 000000000..db9fd93bc
--- /dev/null
+++ b/src/components/standalone/dpi/ApplicationGroups.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('standalone.dpi.create_application_group') }}
+
+
+
+
+
+
+ {{ t('standalone.dpi.create_application_group_description') }}
+
+
+
+
+
+ {{ t('standalone.dpi.create_application_group') }}
+
+
+
+
+
+
+
+ {{ t('common.reset_filters') }}
+
+
+ (page = selected)"
+ @select-page-size="
+ (size) => {
+ pageSize = size
+ page = 1
+ }
+ "
+ />
+
+
+
+
+
diff --git a/src/components/standalone/dpi/ApplicationGroupsTable.vue b/src/components/standalone/dpi/ApplicationGroupsTable.vue
new file mode 100644
index 000000000..cf36ce46b
--- /dev/null
+++ b/src/components/standalone/dpi/ApplicationGroupsTable.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+ {{ t('common.name') }}
+ {{ t('standalone.dpi.content') }}
+ {{ t('standalone.dpi.used_by_rules') }}
+
+
+
+
+
+ {{ group.name }}
+
+
+ {{ contentOf(group) }}
+
+
+
+
+ {{ ruleNamesOf(group.matches).slice(0, 3).join(', ') }}
+
+
+
+
+ {{
+ t('standalone.dpi.n_other_rules', {
+ count: ruleNamesOf(group.matches).length - 3
+ })
+ }}
+
+
+
+
+
+
+
+
+ {{ t('standalone.dpi.not_used') }}
+
+
+
+
+
+
+
+
+ {{ t('common.edit') }}
+
+
+
+
+
+
+
+ emit('select-page', page)"
+ @select-page-size="(size: number) => emit('select-page-size', size)"
+ />
+
+
+
diff --git a/src/components/standalone/dpi/CreateApplicationGroupModal.vue b/src/components/standalone/dpi/CreateApplicationGroupModal.vue
new file mode 100644
index 000000000..a7d7ce8c8
--- /dev/null
+++ b/src/components/standalone/dpi/CreateApplicationGroupModal.vue
@@ -0,0 +1,555 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ category.name }}
+
+
+
+
+ {{
+ isCategorySelected(category.id) && category.selectable === category.total
+ ? t('standalone.dpi.all_selected')
+ : t('standalone.dpi.n_selected', { count: selectedItemsCount(category.id) })
+ }}
+
+
+
+
+
+
+
+ {{
+ t('standalone.dpi.num_of_total_selected_in_category', {
+ selected: selectedItemsInCategory,
+ total: totalItemsInCategory,
+ category: selectedCategory?.name ?? ''
+ })
+ }}
+
+
+ {{
+ showOnlySelectable
+ ? t('standalone.dpi.show_all_items')
+ : t('standalone.dpi.show_only_selectable_items')
+ }}
+
+
+
+
+
+
+
+
+
+
+ {{ item.name }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('standalone.dpi.selected_count', { count: selectedCount }) }}
+
+
+
+
+ {{ selectionLabel(selection) }}
+
+
+
+
+
+
diff --git a/src/components/standalone/dpi/CreateOrEditDpiRuleDrawer.vue b/src/components/standalone/dpi/CreateOrEditDpiRuleDrawer.vue
new file mode 100644
index 000000000..017819abe
--- /dev/null
+++ b/src/components/standalone/dpi/CreateOrEditDpiRuleDrawer.vue
@@ -0,0 +1,510 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('standalone.dpi.general') }}
+
+ {{ t('standalone.dpi.general_description') }}
+
+
+
+ {{ t('standalone.dpi.status') }}
+
+
+
+
+
+
+
+
{{ t('standalone.dpi.source') }}
+
+ {{ t('standalone.dpi.source_description') }}
+
+
+
+
+
+
+
+ {{ t('standalone.dpi.source_addresses_tooltip') }}
+
+
+
+
+
+
+
+
+
{{ t('standalone.dpi.match_and_action') }}
+
+ {{ t('standalone.dpi.match_and_action_description') }}
+
+
+
+
+
+
+
+
+
+
{{ t('standalone.dpi.review') }}
+
+ {{ t('standalone.dpi.review_description') }}
+
+
+
+
+
-
+ {{ t('standalone.dpi.status') }}
+
+ -
+
+ {{ enabled ? t('common.enabled') : t('common.disabled') }}
+
+
+
+
-
+ {{ t('standalone.dpi.rule_name') }}
+
+ - {{ name }}
+
+
+
-
+ {{ t('standalone.dpi.source') }}
+
+ - {{ sourceSummary }}
+
+
+
-
+ {{ t('standalone.dpi.match_type') }}
+
+ -
+
+ {{ matchSummary }}
+
+
+
+
-
+ {{ t('standalone.dpi.action') }}
+
+ -
+
+ {{ action === 'block' ? t('standalone.dpi.block') : t('standalone.dpi.allow') }}
+
+
+
+
-
+ {{ t('standalone.dpi.rule_position') }}
+
+ -
+ {{
+ rulePosition === 'top'
+ ? t('standalone.dpi.position_top')
+ : t('standalone.dpi.position_bottom')
+ }}
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+
+
+
+ {{ t('common.previous') }}
+
+
+ {{ t('common.next') }}
+
+
+
+
+
+ {{ isEditing ? t('common.save') : t('standalone.dpi.add_rule') }}
+
+
+
+
+
+
diff --git a/src/components/standalone/dpi/DeleteApplicationGroupModal.vue b/src/components/standalone/dpi/DeleteApplicationGroupModal.vue
new file mode 100644
index 000000000..5baa5b3d2
--- /dev/null
+++ b/src/components/standalone/dpi/DeleteApplicationGroupModal.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+ {{ t('standalone.dpi.confirm_delete_application_group', { name: group?.name ?? '' }) }}
+
+
+
diff --git a/src/components/standalone/dpi/DeleteDpiRuleModal.vue b/src/components/standalone/dpi/DeleteDpiRuleModal.vue
index d7f371dd7..edcdae392 100644
--- a/src/components/standalone/dpi/DeleteDpiRuleModal.vue
+++ b/src/components/standalone/dpi/DeleteDpiRuleModal.vue
@@ -1,99 +1,74 @@
- {{ t('standalone.dpi.confirm_delete_rule', { iface: rule?.interface }) }}
+ {{ t('standalone.dpi.confirm_delete_rule', { name: rule?.name ?? '' }) }}
-
- {{ error.notificationDetails }}
-
-
+ />
diff --git a/src/components/standalone/dpi/DpiRuleCard.vue b/src/components/standalone/dpi/DpiRuleCard.vue
deleted file mode 100644
index 1db3cb13d..000000000
--- a/src/components/standalone/dpi/DpiRuleCard.vue
+++ /dev/null
@@ -1,169 +0,0 @@
-
-
-
-
-
-
-
-
- {{ rule.interface }}
- {{ rule.device }}
-
-
-
-
-
-
-
- {{ t('common.edit') }}
-
-
-
-
-
-
- {{ t('common.enabled') }}
-
-
-
-
- {{ t('common.disabled') }}
-
-
-
-
-
- {{ t('standalone.dpi.blocked_apps_and_protocols') }}
-
-
-
-
- {{ getHumanizedAppName(app.name) }}
-
-
-
-
-
- {{
- t('standalone.dpi.plus_num_others', {
- num: rule.criteria.length - appsToDisplay.length
- })
- }}
-
-
-
-
-
diff --git a/src/components/standalone/dpi/DpiRules.vue b/src/components/standalone/dpi/DpiRules.vue
index 85279359b..29638aac2 100644
--- a/src/components/standalone/dpi/DpiRules.vue
+++ b/src/components/standalone/dpi/DpiRules.vue
@@ -5,164 +5,310 @@
-
-
- {{ t('standalone.dpi.rules_description') }}
-
-
-
-
-
- {{ t('standalone.dpi.create_rule') }}
-
-
- {{ error.listRulesDetails }}
-
-
-
-
-
-
-
-
-
-
-
+ :description="t(getAxiosErrorMessage(error))"
+ class="mb-8"
+ />
+
+
-
+
+
+
+
+
+ {{ t('standalone.dpi.add_rule') }}
+
+
+ {{ t('standalone.dpi.go_to_application_group') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('standalone.dpi.rules_evaluation_order') }}
+
+
- {{ t('standalone.dpi.create_rule') }}
-
-
-
-
+
+
+
+
+
+
+
+ {{ t('common.reset_filters') }}
+
+
+
+ {{ t('common.reset_filters') }}
+
+
+
toggleRule({ id: rule.id, enabled: !rule.enabled })"
+ @move="onMove"
+ @reorder="onReorder"
+ />
-
-
+
-
diff --git a/src/components/standalone/dpi/DpiRulesTable.vue b/src/components/standalone/dpi/DpiRulesTable.vue
new file mode 100644
index 000000000..e25950021
--- /dev/null
+++ b/src/components/standalone/dpi/DpiRulesTable.vue
@@ -0,0 +1,447 @@
+
+
+
+
+
+
+
+
+
+
+ |
+ {{ header.label }}
+ |
+
+
+
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+ {{ t('standalone.dpi.clear_filters_to_sort_rules') }}
+
+
+
+
+ |
+
+
+ {{
+ rule.name
+ }}
+
+
+
+ {{ t('common.disabled') }}
+
+
+
+ {{ t('standalone.dpi.custom') }}
+
+
+
+ |
+
+
+
+
+ {{ rule.criteria }}
+
+
+
+ {{ rule.criteria }}
+
+
+
+
+
+ {{ rule.source.slice(0, 2).join(', ') }}
+
+
+
+ {{ rule.source[2] }}
+
+
+
+
+ {{
+ t('standalone.dpi.n_other_rules', { count: rule.source.length - 3 })
+ }}
+
+
+
+
+
+
+
+
+
+ {{ t('common.any') }}
+
+
+ |
+
+
+
+
+ {{ rule.criteria }}
+
+
+
+ {{ rule.criteria }}
+
+
+
+
+
+
+
+
+ {{ appgroup.name }}
+
+
+
+ {{ summaryOf(appgroup.id) }}
+
+
+
+
+ {{ appgroup.name }}
+
+
+ -
+
+ |
+
+
+
+ {{
+ rule.action === 'allow'
+ ? t('standalone.dpi.allow')
+ : t('standalone.dpi.block')
+ }}
+
+ |
+
+
+
+
+
+
+ {{ t('common.edit') }}
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/src/components/standalone/dpi/ManageDpiRuleModal.vue b/src/components/standalone/dpi/ManageDpiRuleModal.vue
deleted file mode 100644
index 92ac9cf95..000000000
--- a/src/components/standalone/dpi/ManageDpiRuleModal.vue
+++ /dev/null
@@ -1,657 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- {{ error.listDevicesErrorDetails }}
-
-
-
-
-
-
- {{ t('standalone.dpi.source_tooltip') }}
-
-
-
-
-
-
-
-
-
- {{
- t('standalone.dpi.num_apps_and_protocols_selected', {
- num: numAppsSelected,
- total: totalNumApps
- })
- }}
-
-
-
-
-
-
-
- {{ error.listPopularAppsErrorDetails }}
-
-
-
-
-
- {{ error.listApplicationsErrorDetails }}
-
-
-
-
-
-
-
-
-
-
- {{ t('standalone.dpi.search_results') }}
-
-
{{
- t('standalone.dpi.clear_search')
- }}
-
-
-
-
-
-
-
-
-
-
-
{{ getHumanizedAppName(app.name) }}
-
-
-
- {{ t('standalone.dpi.available_with_subscription') }}
-
-
- {{ t(`standalone.dpi.type_${app.type}`) }}
-
- / {{ getHumanizedCategoryName(app.category?.name) }}
-
-
-
-
-
-
-
-
-
-
-
- {{ t(errorBag.getFirstI18nKeyFor('apps')) }}
-
-
-
- {{ t('standalone.dpi.continue_typing_to_show_other_search_results') }}
-
-
-
-
-
-
-
- {{ error.addRuleErrorDetails }}
-
-
-
-
- {{ error.editRuleErrorDetails }}
-
-
-
-
-
diff --git a/src/components/standalone/dpi/RenameDpiRuleDrawer.vue b/src/components/standalone/dpi/RenameDpiRuleDrawer.vue
new file mode 100644
index 000000000..7ed7f4721
--- /dev/null
+++ b/src/components/standalone/dpi/RenameDpiRuleDrawer.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+ {{ t('common.save') }}
+
+
+
+
+
diff --git a/src/composables/__tests__/useApplicationGroups.spec.ts b/src/composables/__tests__/useApplicationGroups.spec.ts
new file mode 100644
index 000000000..67d43b231
--- /dev/null
+++ b/src/composables/__tests__/useApplicationGroups.spec.ts
@@ -0,0 +1,197 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest'
+import * as v from 'valibot'
+import {
+ groupContentSummary,
+ selectionsFromGroup,
+ selectionsToPayload,
+ type DpiApplicationGroup,
+ type DpiGroupSelection,
+ type DpiMemberResolver
+} from '@/composables/useApplicationGroups'
+
+function selection(over: Partial): DpiGroupSelection {
+ return { type: 'item', kind: 'applications', id: 'x', name: 'X', categoryId: 'c', ...over }
+}
+
+describe('selectionsToPayload', () => {
+ it('always sends the four lists, so edit does not keep stale members', () => {
+ expect(selectionsToPayload('empty', [])).toEqual({
+ name: 'empty',
+ applications: [],
+ application_categories: [],
+ protocols: [],
+ protocol_categories: []
+ })
+ })
+
+ it('splits the four kinds of member into their own list', () => {
+ const payload = selectionsToPayload('mixed', [
+ selection({ kind: 'applications', type: 'item', id: 'netify.amazon' }),
+ selection({ kind: 'applications', type: 'category', id: 'cybersecurity' }),
+ selection({ kind: 'protocols', type: 'item', id: 'HTTP/Connect' }),
+ selection({ kind: 'protocols', type: 'category', id: 'games' })
+ ])
+ expect(payload).toEqual({
+ name: 'mixed',
+ applications: ['netify.amazon'],
+ application_categories: ['cybersecurity'],
+ protocols: ['HTTP/Connect'],
+ protocol_categories: ['games']
+ })
+ })
+
+ it('sends the id, not the label: the backend matches on the engine name', () => {
+ const payload = selectionsToPayload('p', [
+ selection({ kind: 'protocols', type: 'item', id: 'HTTP/Connect', name: 'HTTP Connect' })
+ ])
+ expect(payload.protocols).toEqual(['HTTP/Connect'])
+ })
+
+ it('keeps duplicates apart when the same id exists in both tabs', () => {
+ const payload = selectionsToPayload('g', [
+ selection({ kind: 'applications', type: 'category', id: 'games' }),
+ selection({ kind: 'protocols', type: 'category', id: 'games' })
+ ])
+ expect(payload.application_categories).toEqual(['games'])
+ expect(payload.protocol_categories).toEqual(['games'])
+ })
+})
+
+describe('selectionsFromGroup', () => {
+ const group: DpiApplicationGroup = {
+ id: 'ns_1',
+ name: 'Group',
+ applications: ['netify.amazon', 'netify.gone'],
+ application_categories: ['cybersecurity'],
+ protocols: ['HTTP/Connect'],
+ protocol_categories: ['games'],
+ used: false,
+ matches: []
+ }
+
+ const resolver: DpiMemberResolver = {
+ labelOf: (kind, type, id) => {
+ const known: Record = {
+ 'applications:item:netify.amazon': 'Amazon',
+ 'applications:category:cybersecurity': 'Cybersecurity',
+ 'protocols:item:HTTP/Connect': 'HTTP Connect',
+ 'protocols:category:games': 'Games'
+ }
+ return known[`${kind}:${type}:${id}`] ?? id
+ },
+ categoryOf: (_kind, id) => (id === 'netify.amazon' ? 'business' : '')
+ }
+
+ it('round trips through the payload without losing a member', () => {
+ const selections = selectionsFromGroup(group, resolver)
+ const payload = selectionsToPayload(group.name, selections)
+ expect(payload).toEqual({
+ name: group.name,
+ applications: group.applications,
+ application_categories: group.application_categories,
+ protocols: group.protocols,
+ protocol_categories: group.protocol_categories
+ })
+ })
+
+ it('keeps a member the catalog does not know, labelled with its stored value', () => {
+ const selections = selectionsFromGroup(group, resolver)
+ const orphan = selections.find((s) => s.id === 'netify.gone')
+ expect(orphan).toBeDefined()
+ expect(orphan!.name).toBe('netify.gone')
+ })
+
+ it('resolves the category of an entry, and uses the tag itself for a category', () => {
+ const selections = selectionsFromGroup(group, resolver)
+ expect(selections.find((s) => s.id === 'netify.amazon')!.categoryId).toBe('business')
+ expect(selections.find((s) => s.id === 'cybersecurity')!.categoryId).toBe('cybersecurity')
+ })
+
+ it('returns nothing for a group with no members', () => {
+ const empty = {
+ ...group,
+ applications: [],
+ application_categories: [],
+ protocols: [],
+ protocol_categories: []
+ }
+ expect(selectionsFromGroup(empty, resolver)).toEqual([])
+ })
+})
+
+const nameSchema = v.object({
+ name: v.pipe(
+ v.string(),
+ v.trim(),
+ v.minLength(1, 'name_required'),
+ v.maxLength(64, 'name_too_long')
+ )
+})
+
+describe('group name schema', () => {
+ it.each([
+ ['', false],
+ [' ', false],
+ ['a', true],
+ ['a'.repeat(64), true],
+ ['a'.repeat(65), false]
+ ])('%j → valid: %s', (name, expected) => {
+ expect(v.safeParse(nameSchema, { name }).success).toBe(expected)
+ })
+
+ it('reports the message id the i18n keys are named after', () => {
+ const empty = v.safeParse(nameSchema, { name: '' })
+ expect(empty.issues?.[0]?.message).toBe('name_required')
+ const long = v.safeParse(nameSchema, { name: 'a'.repeat(65) })
+ expect(long.issues?.[0]?.message).toBe('name_too_long')
+ })
+
+ it('trims before measuring, so trailing spaces do not pass the limit', () => {
+ expect(v.safeParse(nameSchema, { name: `${'a'.repeat(64)} ` }).success).toBe(true)
+ })
+})
+
+describe('groupContentSummary', () => {
+ const group = (over: Partial = {}): DpiApplicationGroup => ({
+ id: 'ns_1',
+ name: 'G',
+ applications: [],
+ application_categories: [],
+ protocols: [],
+ protocol_categories: [],
+ used: false,
+ matches: [],
+ ...over
+ })
+
+ it('lists the parts in the order the design shows them', () => {
+ expect(
+ groupContentSummary(
+ group({
+ applications: ['a1', 'a2'],
+ application_categories: ['c1'],
+ protocols: ['p1'],
+ protocol_categories: ['pc1']
+ })
+ )
+ ).toEqual([
+ { key: 'n_app_categories', count: 1 },
+ { key: 'n_protocol_categories', count: 1 },
+ { key: 'n_applications', count: 2 },
+ { key: 'n_protocols', count: 1 }
+ ])
+ })
+
+ it('leaves out the parts with no members, so the line has no empty pieces', () => {
+ expect(groupContentSummary(group({ applications: ['a1'] }))).toEqual([
+ { key: 'n_applications', count: 1 }
+ ])
+ })
+
+ it('returns nothing for a group with no members at all', () => {
+ expect(groupContentSummary(group())).toEqual([])
+ })
+})
diff --git a/src/composables/__tests__/useDpiCatalog.spec.ts b/src/composables/__tests__/useDpiCatalog.spec.ts
new file mode 100644
index 000000000..9f7188edf
--- /dev/null
+++ b/src/composables/__tests__/useDpiCatalog.spec.ts
@@ -0,0 +1,147 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { describe, expect, it, vi } from 'vitest'
+import { createApp, defineComponent, h, nextTick, ref } from 'vue'
+import { createPinia } from 'pinia'
+import { createI18n } from 'vue-i18n'
+import { VueQueryPlugin } from '@tanstack/vue-query'
+import en from '@/i18n/en.json'
+
+vi.mock('@/lib/standalone/ubus.ts', async (importOriginal) => {
+ const original = await importOriginal()
+ return { ...original, ubusCall: vi.fn() }
+})
+vi.mock('@/lib/standalone/ubus', async (importOriginal) => {
+ const original = await importOriginal()
+ return { ...original, ubusCall: vi.fn() }
+})
+
+import { ubusCall } from '@/lib/standalone/ubus'
+import { useDpiCatalog, UNCATEGORIZED } from '@/composables/useDpiCatalog'
+
+const mockUbusCall = vi.mocked(ubusCall)
+
+const BUSINESS = { id: 9, label: 'Business', tag: 'business' }
+
+const PROTOCOL_CATALOG = [
+ { id: 1, tag: 'http-connect', label: 'HTTP Connect', category: BUSINESS },
+ { id: 2, tag: 'ftp-control', label: 'FTP Control', category: BUSINESS },
+ { id: 3, tag: 'mystery', label: 'Mystery' }
+]
+const LOADED_PROTOCOLS = [
+ { id: 1, name: 'HTTP/Connect' },
+ { id: 3, name: 'MYSTERY' },
+ { id: 99, name: 'DATASAVER' },
+ { id: 100, name: 'Unknown' }
+]
+const APPLICATION_CATALOG = [
+ { id: 20, tag: 'netify.amazon', label: 'Amazon', category: BUSINESS },
+ { id: 21, tag: 'netify.retired', label: 'Retired App', category: BUSINESS, active: false }
+]
+const LOADED_APPLICATIONS = [
+ { id: 20, name: 'netify.amazon' },
+ { id: 21, name: 'netify.retired' }
+]
+
+function respond(method: string) {
+ const values: Record = {
+ 'list-application-catalog': APPLICATION_CATALOG,
+ 'list-protocol-catalog': PROTOCOL_CATALOG,
+ 'list-loaded-applications': LOADED_APPLICATIONS,
+ 'list-loaded-protocols': LOADED_PROTOCOLS
+ }
+ return { data: { values: values[method] } }
+}
+
+type Probe = {
+ categories?: { id: string; name: string; selectable: number }[]
+ items?: { id: string; name: string; disabled: boolean }[]
+}
+
+async function runCatalog(kindValue: 'applications' | 'protocols', categoryIdValue: string) {
+ mockUbusCall.mockReset()
+ mockUbusCall.mockImplementation(async (_path, method) => respond(method))
+ const seen: Probe = {}
+ const Component = defineComponent({
+ setup() {
+ const kind = ref(kindValue)
+ const categoryId = ref(categoryIdValue)
+ const { categories, items } = useDpiCatalog(kind, categoryId)
+ return () => {
+ seen.categories = categories.value.map(({ id, name, selectable }) => ({
+ id,
+ name,
+ selectable
+ }))
+ seen.items = items.value.map(({ id, name, disabled }) => ({ id, name, disabled }))
+ return h('div')
+ }
+ }
+ })
+ const app = createApp(Component)
+ app.use(createPinia())
+ app.use(createI18n({ legacy: false, locale: 'en', messages: { en } }))
+ app.use(VueQueryPlugin)
+ app.mount(document.createElement('div'))
+ for (let i = 0; i < 20; i++) {
+ await nextTick()
+ await Promise.resolve()
+ }
+ await new Promise((resolve) => setTimeout(resolve, 30))
+ await nextTick()
+ app.unmount()
+ return seen
+}
+
+describe('useDpiCatalog item ids', () => {
+ it('identifies a loaded protocol by its engine name, not the catalog tag', async () => {
+ const seen = await runCatalog('protocols', 'business')
+ const loaded = seen.items!.find((item) => !item.disabled)
+ expect(loaded).toEqual({ id: 'HTTP/Connect', name: 'HTTP Connect', disabled: false })
+ })
+
+ it('leaves the catalog tag on entries the engine has not loaded, and disables them', async () => {
+ const seen = await runCatalog('protocols', 'business')
+ const notLoaded = seen.items!.find((item) => item.name === 'FTP Control')
+ expect(notLoaded).toEqual({ id: 'ftp-control', name: 'FTP Control', disabled: true })
+ })
+
+ it('keys applications the same way, where tag and engine name coincide', async () => {
+ const seen = await runCatalog('applications', 'business')
+ expect(seen.items).toEqual([{ id: 'netify.amazon', name: 'Amazon', disabled: false }])
+ })
+})
+
+describe('useDpiCatalog uncategorized group', () => {
+ it('merges catalog entries with no category and entries the catalog does not know at all', async () => {
+ const seen = await runCatalog('protocols', UNCATEGORIZED)
+ const uncategorized = seen.categories!.find((category) => category.id === UNCATEGORIZED)
+ expect(uncategorized).toBeDefined()
+ expect(uncategorized!.selectable).toBe(2)
+ expect(seen.items).toEqual([
+ { id: 'DATASAVER', name: 'DATASAVER', disabled: false },
+ { id: 'MYSTERY', name: 'Mystery', disabled: false }
+ ])
+ })
+
+ it('leaves a real category its selectable count', async () => {
+ const seen = await runCatalog('protocols', 'business')
+ const business = seen.categories!.find((category) => category.id === 'business')
+ expect(business!.selectable).toBe(1)
+ })
+})
+
+describe('useDpiCatalog inactive applications', () => {
+ it('never shows an application the catalog marks inactive, even if the engine has loaded it', async () => {
+ const seen = await runCatalog('applications', 'business')
+ expect(seen.items).toEqual([{ id: 'netify.amazon', name: 'Amazon', disabled: false }])
+ })
+})
+
+describe('useDpiCatalog unclassified protocol', () => {
+ it('never shows the engine catch-all for unclassified traffic', async () => {
+ const seen = await runCatalog('protocols', UNCATEGORIZED)
+ expect(seen.items!.some((item) => item.name.toLowerCase() === 'unknown')).toBe(false)
+ })
+})
diff --git a/src/composables/__tests__/useDpiRules.spec.ts b/src/composables/__tests__/useDpiRules.spec.ts
new file mode 100644
index 000000000..d3f261025
--- /dev/null
+++ b/src/composables/__tests__/useDpiRules.spec.ts
@@ -0,0 +1,73 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest'
+import { moveRuleIdToEdge, reorderRuleIds, type DpiRule } from '@/composables/useDpiRules'
+
+function rules(...ids: string[]): DpiRule[] {
+ return ids.map((id, index) => ({
+ id,
+ name: id,
+ enabled: true,
+ action: 'block',
+ source: [],
+ appgroups: [],
+ managed: true,
+ index
+ }))
+}
+
+const all = rules('a', 'b', 'c', 'd', 'e')
+
+describe('reorderRuleIds', () => {
+ it('names every rule exactly once, whatever the move', () => {
+ const order = reorderRuleIds(all, 'd', 1)
+ expect(order).toHaveLength(all.length)
+ expect(new Set(order).size).toBe(all.length)
+ })
+
+ it('moves a rule up to the position of the target', () => {
+ expect(reorderRuleIds(all, 'd', 1)).toEqual(['a', 'd', 'b', 'c', 'e'])
+ })
+
+ it('moves a rule down, accounting for the gap it leaves behind', () => {
+ expect(reorderRuleIds(all, 'a', 3)).toEqual(['b', 'c', 'a', 'd', 'e'])
+ })
+
+ it('is a no-op when the target is the current position', () => {
+ expect(reorderRuleIds(all, 'c', 2)).toEqual(['a', 'b', 'c', 'd', 'e'])
+ })
+
+ it('clamps a target beyond the end instead of dropping the rule', () => {
+ expect(reorderRuleIds(all, 'a', 99)).toEqual(['b', 'c', 'd', 'e', 'a'])
+ })
+
+ it('clamps a negative target', () => {
+ expect(reorderRuleIds(all, 'e', -3)).toEqual(['e', 'a', 'b', 'c', 'd'])
+ })
+
+ it('returns the untouched order for an id that is not in the list', () => {
+ expect(reorderRuleIds(all, 'zzz', 0)).toEqual(['a', 'b', 'c', 'd', 'e'])
+ })
+
+ it('handles a single rule', () => {
+ expect(reorderRuleIds(rules('only'), 'only', 0)).toEqual(['only'])
+ })
+})
+
+describe('moveRuleIdToEdge', () => {
+ it('moves to the top without losing or repeating a rule', () => {
+ const order = moveRuleIdToEdge(all, 'd', 'top')
+ expect(order).toEqual(['d', 'a', 'b', 'c', 'e'])
+ expect(new Set(order).size).toBe(all.length)
+ })
+
+ it('moves to the bottom', () => {
+ expect(moveRuleIdToEdge(all, 'b', 'bottom')).toEqual(['a', 'c', 'd', 'e', 'b'])
+ })
+
+ it('is a no-op for a rule already at that edge', () => {
+ expect(moveRuleIdToEdge(all, 'a', 'top')).toEqual(['a', 'b', 'c', 'd', 'e'])
+ expect(moveRuleIdToEdge(all, 'e', 'bottom')).toEqual(['a', 'b', 'c', 'd', 'e'])
+ })
+})
diff --git a/src/composables/useApplicationGroups.ts b/src/composables/useApplicationGroups.ts
new file mode 100644
index 000000000..884630c6c
--- /dev/null
+++ b/src/composables/useApplicationGroups.ts
@@ -0,0 +1,189 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import type { Ref } from 'vue'
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { ubusCall } from '@/lib/standalone/ubus'
+import { useUciPendingChangesStore } from '@/stores/standalone/uciPendingChanges'
+import type { DpiCatalogKind } from '@/composables/useDpiCatalog'
+
+/** A DPI application group, as `list-appgroups` returns it. */
+export type DpiApplicationGroup = {
+ id: string
+ name: string
+ applications: string[]
+ application_categories: string[]
+ protocols: string[]
+ protocol_categories: string[]
+ used: boolean
+ matches: string[]
+}
+
+export type DpiApplicationGroupPayload = {
+ name: string
+ applications: string[]
+ application_categories: string[]
+ protocols: string[]
+ protocol_categories: string[]
+}
+
+export type DpiApplicationGroupsPage = {
+ data: DpiApplicationGroup[]
+ meta: { last_page: number; total: number }
+}
+
+type ListAppGroupsResponse = { data: { values: DpiApplicationGroupsPage } }
+type AppGroupIdResponse = { data: { id: string } }
+
+export const APPLICATION_GROUPS_KEY = ['dpi', 'application-groups']
+
+export function useApplicationGroups() {
+ return useQuery({
+ queryKey: [...APPLICATION_GROUPS_KEY, 'all'],
+ queryFn: ({ signal }) =>
+ ubusCall('ns.dpi', 'list-appgroups', {}, { signal }),
+ select: (res) => res.data.values.data
+ })
+}
+
+export function useApplicationGroupsPage(params: {
+ search: Ref
+ page: Ref
+ pageSize: Ref
+}) {
+ return useQuery({
+ queryKey: [...APPLICATION_GROUPS_KEY, 'page', params.search, params.page, params.pageSize],
+ queryFn: ({ signal }) =>
+ ubusCall(
+ 'ns.dpi',
+ 'list-appgroups',
+ {
+ search: params.search.value || undefined,
+ limit: params.pageSize.value,
+ page: params.page.value
+ },
+ { signal }
+ ),
+ select: (res) => res.data.values,
+ placeholderData: keepPreviousData
+ })
+}
+
+function useApplicationGroupsInvalidation() {
+ const queryClient = useQueryClient()
+ const uci = useUciPendingChangesStore()
+
+ return () =>
+ Promise.all([
+ queryClient.invalidateQueries({ queryKey: APPLICATION_GROUPS_KEY }),
+ uci.getChanges()
+ ])
+}
+
+export function useCreateApplicationGroup() {
+ const invalidate = useApplicationGroupsInvalidation()
+
+ return useMutation({
+ mutationFn: (payload: DpiApplicationGroupPayload) =>
+ ubusCall('ns.dpi', 'add-appgroup', payload),
+ onSuccess: invalidate
+ })
+}
+
+export function useEditApplicationGroup() {
+ const invalidate = useApplicationGroupsInvalidation()
+
+ return useMutation({
+ mutationFn: (payload: DpiApplicationGroupPayload & { id: string }) =>
+ ubusCall('ns.dpi', 'edit-appgroup', payload),
+ onSuccess: invalidate
+ })
+}
+
+export function useDeleteApplicationGroup() {
+ const invalidate = useApplicationGroupsInvalidation()
+
+ return useMutation({
+ mutationFn: (id: string) =>
+ ubusCall<{ data: { message: string } }>('ns.dpi', 'delete-appgroup', { id }),
+ onSuccess: invalidate
+ })
+}
+
+export type DpiGroupMemberType = 'item' | 'category'
+
+export type DpiGroupSelection = {
+ type: DpiGroupMemberType
+ kind: DpiCatalogKind
+ id: string
+ name: string
+ categoryId: string
+}
+
+export type DpiMemberResolver = {
+ labelOf: (kind: DpiCatalogKind, type: DpiGroupMemberType, id: string) => string
+ categoryOf: (kind: DpiCatalogKind, id: string) => string
+}
+
+export function selectionsToPayload(
+ name: string,
+ selections: DpiGroupSelection[]
+): DpiApplicationGroupPayload {
+ const payload: DpiApplicationGroupPayload = {
+ name,
+ applications: [],
+ application_categories: [],
+ protocols: [],
+ protocol_categories: []
+ }
+
+ for (const selection of selections) {
+ if (selection.kind === 'applications') {
+ if (selection.type === 'item') {
+ payload.applications.push(selection.id)
+ } else {
+ payload.application_categories.push(selection.id)
+ }
+ } else if (selection.type === 'item') {
+ payload.protocols.push(selection.id)
+ } else {
+ payload.protocol_categories.push(selection.id)
+ }
+ }
+
+ return payload
+}
+
+export function selectionsFromGroup(
+ group: DpiApplicationGroup,
+ resolver: DpiMemberResolver
+): DpiGroupSelection[] {
+ const build = (
+ ids: string[],
+ kind: DpiCatalogKind,
+ type: DpiGroupMemberType
+ ): DpiGroupSelection[] =>
+ ids.map((id) => ({
+ type,
+ kind,
+ id,
+ name: resolver.labelOf(kind, type, id),
+ categoryId: type === 'category' ? id : resolver.categoryOf(kind, id)
+ }))
+
+ return [
+ ...build(group.applications, 'applications', 'item'),
+ ...build(group.application_categories, 'applications', 'category'),
+ ...build(group.protocols, 'protocols', 'item'),
+ ...build(group.protocol_categories, 'protocols', 'category')
+ ]
+}
+
+export function groupContentSummary(group: DpiApplicationGroup) {
+ return [
+ { key: 'n_app_categories', count: group.application_categories.length },
+ { key: 'n_protocol_categories', count: group.protocol_categories.length },
+ { key: 'n_applications', count: group.applications.length },
+ { key: 'n_protocols', count: group.protocols.length }
+ ].filter((part) => part.count > 0)
+}
diff --git a/src/composables/useDpiCatalog.ts b/src/composables/useDpiCatalog.ts
new file mode 100644
index 000000000..7cf5c30d1
--- /dev/null
+++ b/src/composables/useDpiCatalog.ts
@@ -0,0 +1,312 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { computed, type Ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useQuery } from '@tanstack/vue-query'
+import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
+import {
+ faArrowsRotate,
+ faBookOpen,
+ faBriefcase,
+ faBug,
+ faBullhorn,
+ faCloud,
+ faCoins,
+ faComments,
+ faDesktop,
+ faDice,
+ faEnvelope,
+ faEyeSlash,
+ faFolderOpen,
+ faFutbol,
+ faGamepad,
+ faGraduationCap,
+ faHashtag,
+ faLandmark,
+ faLaptopCode,
+ faLock,
+ faMasksTheater,
+ faMicrochip,
+ faNewspaper,
+ faPersonHiking,
+ faPlay,
+ faServer,
+ faShapes,
+ faShareNodes,
+ faShieldHalved,
+ faTowerCell,
+ faVideo,
+ faWindowMaximize
+} from '@fortawesome/free-solid-svg-icons'
+import { ubusCall } from '@/lib/standalone/ubus'
+import {
+ useNetifydStore,
+ type Application,
+ type Protocol,
+ type Response
+} from '@/stores/standalone/netifyd'
+
+export const UNCATEGORIZED = ''
+
+export type DpiCatalogCategory = {
+ id: string
+ name: string
+ icon: IconDefinition
+ total: number
+ selectable: number
+}
+
+export type DpiCatalogItem = {
+ id: string
+ name: string
+ disabled: boolean
+ logo?: string
+}
+
+const CATEGORY_ICONS: Record = {
+ adult: faEyeSlash,
+ advertiser: faBullhorn,
+ artsandentertainment: faMasksTheater,
+ business: faBriefcase,
+ careerandeducation: faGraduationCap,
+ cdn: faCloud,
+ cybersecurity: faShieldHalved,
+ deviceiot: faMicrochip,
+ fileserver: faFolderOpen,
+ filesharing: faShareNodes,
+ financial: faCoins,
+ gambling: faDice,
+ games: faGamepad,
+ government: faLandmark,
+ hosting: faServer,
+ isptelco: faTowerCell,
+ mail: faEnvelope,
+ malware: faBug,
+ messaging: faComments,
+ news: faNewspaper,
+ ossoftwareupdates: faArrowsRotate,
+ portal: faWindowMaximize,
+ recreation: faPersonHiking,
+ reference: faBookOpen,
+ remotedesktop: faDesktop,
+ socialmedia: faHashtag,
+ sports: faFutbol,
+ streamingmedia: faPlay,
+ technology: faLaptopCode,
+ voipconferencing: faVideo,
+ vpnandproxy: faLock
+}
+
+export type DpiCatalogKind = 'applications' | 'protocols'
+
+function categoryIconKey(label: string) {
+ return label.toLowerCase().replace(/[^a-z0-9]/g, '')
+}
+
+export type DpiCatalogEntry = {
+ id: number
+ name: string
+ label: string
+}
+
+type LoadedEntry = {
+ id: number
+ name: string
+}
+
+export const DPI_LOADED_APPLICATIONS_KEY = ['dpi', 'loaded-applications']
+export const DPI_LOADED_PROTOCOLS_KEY = ['dpi', 'loaded-protocols']
+
+export function useLoadedApplications() {
+ return useQuery({
+ queryKey: DPI_LOADED_APPLICATIONS_KEY,
+ queryFn: ({ signal }) =>
+ ubusCall>('ns.dpi', 'list-loaded-applications', {}, { signal }),
+ select: (res) => res.data.values,
+ staleTime: 5 * 60 * 1000
+ })
+}
+
+export function useLoadedProtocols() {
+ return useQuery({
+ queryKey: DPI_LOADED_PROTOCOLS_KEY,
+ queryFn: ({ signal }) =>
+ ubusCall>('ns.dpi', 'list-loaded-protocols', {}, { signal }),
+ select: (res) => res.data.values,
+ staleTime: 5 * 60 * 1000
+ })
+}
+
+export function useDpiCatalog(kind: Ref, categoryId: Ref) {
+ const { t } = useI18n()
+ const netifydStore = useNetifydStore()
+ const loadedApplications = useLoadedApplications()
+ const loadedProtocols = useLoadedProtocols()
+
+ const isApplications = computed(() => kind.value === 'applications')
+
+ const catalogQuery = computed(() =>
+ isApplications.value ? netifydStore.applications : netifydStore.protocols
+ )
+ const loadedQuery = computed(() => (isApplications.value ? loadedApplications : loadedProtocols))
+
+ const loaded = computed(() => loadedQuery.value.data.value ?? [])
+ const loadedById = computed(() => new Map(loaded.value.map((entry) => [entry.id, entry.name])))
+
+ type CrossedEntry = DpiCatalogItem & { categoryTag: string; categoryLabel: string }
+
+ const entries = computed(() => {
+ const catalog = catalogQuery.value.data ?? []
+
+ const visibleCatalog = isApplications.value
+ ? catalog.filter((entry) => (entry as Application).active !== false)
+ : catalog
+
+ const fromCatalog = visibleCatalog.map((entry) => ({
+ id: loadedById.value.get(entry.id) ?? entry.tag,
+ name: entry.label,
+ logo: (entry as Application).icon,
+ disabled: !loadedById.value.has(entry.id),
+ categoryTag: entry.category?.tag ?? '',
+ categoryLabel: entry.category?.label ?? ''
+ }))
+
+ const catalogIds = new Set(catalog.map((entry) => entry.id))
+ const unknownToCatalog = loaded.value
+ .filter((entry) => !catalogIds.has(entry.id))
+ .map((entry) => ({
+ id: entry.name,
+ name: entry.name,
+ disabled: false,
+ categoryTag: '',
+ categoryLabel: ''
+ }))
+
+ const merged = [...fromCatalog, ...unknownToCatalog]
+
+ return isApplications.value
+ ? merged
+ : merged.filter((entry) => entry.name.toLowerCase() !== 'unknown')
+ })
+
+ const entriesByCategory = computed(() => {
+ const groups = new Map()
+ for (const entry of entries.value) {
+ const group = groups.get(entry.categoryTag)
+ if (group) {
+ group.push(entry)
+ } else {
+ groups.set(entry.categoryTag, [entry])
+ }
+ }
+ return groups
+ })
+
+ const categories = computed(() =>
+ [...entriesByCategory.value.entries()]
+ .map(([id, group]) => ({
+ id,
+ name: group[0]!.categoryLabel || t('standalone.dpi.uncategorized'),
+ icon: CATEGORY_ICONS[categoryIconKey(group[0]!.categoryLabel)] ?? faShapes,
+ total: group.length,
+ selectable: group.filter((entry) => !entry.disabled).length
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name))
+ )
+
+ function itemsOf(id: string): DpiCatalogItem[] {
+ return (entriesByCategory.value.get(id) ?? [])
+ .map(({ id: entryId, name, disabled, logo }) => ({ id: entryId, name, disabled, logo }))
+ .sort((a, b) => a.name.localeCompare(b.name))
+ }
+
+ const items = computed(() => itemsOf(categoryId.value))
+
+ const isLoading = computed(
+ () => catalogQuery.value.isLoading || loadedQuery.value.isLoading.value
+ )
+ const isError = computed(() => catalogQuery.value.isError || loadedQuery.value.isError.value)
+ const error = computed(() => loadedQuery.value.error.value ?? catalogQuery.value.error)
+
+ return { categories, items, itemsOf, isLoading, isError, error }
+}
+
+export function useDpiCatalogLabels() {
+ const netifydStore = useNetifydStore()
+ const loadedApplications = useLoadedApplications()
+ const loadedProtocols = useLoadedProtocols()
+
+ function labelByEngineName(
+ catalog: (Application | Protocol)[],
+ loaded: LoadedEntry[] | undefined
+ ) {
+ const labels = new Map()
+ const byId = new Map(catalog.map((entry) => [entry.id, entry]))
+ for (const entry of loaded ?? []) {
+ const catalogEntry = byId.get(entry.id)
+ labels.set(entry.name, catalogEntry?.label ?? entry.name)
+ }
+ return labels
+ }
+
+ function categoryLabels(catalog: (Application | Protocol)[]) {
+ const labels = new Map()
+ for (const entry of catalog) {
+ if (entry.category) {
+ labels.set(entry.category.tag, entry.category.label)
+ }
+ }
+ return labels
+ }
+
+ const applicationLabels = computed(() =>
+ labelByEngineName(netifydStore.applications.data ?? [], loadedApplications.data.value)
+ )
+ const protocolLabels = computed(() =>
+ labelByEngineName(netifydStore.protocols.data ?? [], loadedProtocols.data.value)
+ )
+ const applicationCategoryLabels = computed(() =>
+ categoryLabels(netifydStore.applications.data ?? [])
+ )
+ const protocolCategoryLabels = computed(() => categoryLabels(netifydStore.protocols.data ?? []))
+
+ function categoryByEngineName(
+ catalog: (Application | Protocol)[],
+ loaded: LoadedEntry[] | undefined
+ ) {
+ const categories = new Map()
+ const byId = new Map(catalog.map((entry) => [entry.id, entry]))
+ for (const entry of loaded ?? []) {
+ categories.set(entry.name, byId.get(entry.id)?.category?.tag ?? UNCATEGORIZED)
+ }
+ return categories
+ }
+
+ const applicationCategoryOf = computed(() =>
+ categoryByEngineName(netifydStore.applications.data ?? [], loadedApplications.data.value)
+ )
+ const protocolCategoryOf = computed(() =>
+ categoryByEngineName(netifydStore.protocols.data ?? [], loadedProtocols.data.value)
+ )
+
+ function categoryOf(kind: DpiCatalogKind, id: string) {
+ const categories =
+ kind === 'applications' ? applicationCategoryOf.value : protocolCategoryOf.value
+ return categories.get(id) ?? UNCATEGORIZED
+ }
+
+ function labelOf(kind: DpiCatalogKind, type: 'item' | 'category', id: string) {
+ const labels =
+ type === 'category'
+ ? kind === 'applications'
+ ? applicationCategoryLabels.value
+ : protocolCategoryLabels.value
+ : kind === 'applications'
+ ? applicationLabels.value
+ : protocolLabels.value
+ return labels.get(id) ?? id
+ }
+
+ return { labelOf, categoryOf }
+}
diff --git a/src/composables/useDpiRules.ts b/src/composables/useDpiRules.ts
new file mode 100644
index 000000000..5dfd86512
--- /dev/null
+++ b/src/composables/useDpiRules.ts
@@ -0,0 +1,149 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { ubusCall } from '@/lib/standalone/ubus'
+import { useUciPendingChangesStore } from '@/stores/standalone/uciPendingChanges'
+
+export type DpiRuleAction = 'block' | 'allow'
+
+export type DpiRuleAppGroup = {
+ id: string
+ name: string
+}
+
+export type DpiRule = {
+ id: string
+ name: string
+ enabled: boolean
+ action: DpiRuleAction
+ source: string[]
+ appgroups: DpiRuleAppGroup[]
+ managed: boolean
+ index: number
+ criteria?: string
+}
+
+export type DpiRulePayload = {
+ name: string
+ enabled: boolean
+ action: DpiRuleAction
+ source: string[]
+ appgroups: string[]
+}
+
+export type DpiRulePosition = 'top' | 'bottom'
+
+export type DpiRulesPage = {
+ data: DpiRule[]
+ meta: { last_page: number; total: number }
+}
+
+type ListRulesResponse = { data: { values: DpiRulesPage | DpiRule[] } }
+type RuleIdResponse = { data: { id: string } }
+type MessageResponse = { data: { message: string } }
+
+export const DPI_RULES_KEY = ['dpi', 'rules']
+
+export function useDpiRules() {
+ return useQuery({
+ queryKey: [...DPI_RULES_KEY, 'all'],
+ queryFn: ({ signal }) => ubusCall('ns.dpi', 'list-rules', {}, { signal }),
+ select: (res) => {
+ const values = res.data.values
+ return Array.isArray(values) ? values : values.data
+ }
+ })
+}
+
+function useDpiRulesInvalidation() {
+ const queryClient = useQueryClient()
+ const uci = useUciPendingChangesStore()
+
+ return () =>
+ Promise.all([queryClient.invalidateQueries({ queryKey: DPI_RULES_KEY }), uci.getChanges()])
+}
+
+export function useCreateDpiRule() {
+ const invalidate = useDpiRulesInvalidation()
+
+ return useMutation({
+ mutationFn: (payload: DpiRulePayload & { position: DpiRulePosition }) =>
+ ubusCall('ns.dpi', 'add-rule', payload),
+ onSuccess: invalidate
+ })
+}
+
+export function useEditDpiRule() {
+ const invalidate = useDpiRulesInvalidation()
+
+ return useMutation({
+ mutationFn: (payload: DpiRulePayload & { id: string }) =>
+ ubusCall('ns.dpi', 'edit-rule', payload),
+ onSuccess: invalidate
+ })
+}
+
+export function useDeleteDpiRule() {
+ const invalidate = useDpiRulesInvalidation()
+
+ return useMutation({
+ mutationFn: (id: string) => ubusCall('ns.dpi', 'delete-rule', { id }),
+ onSuccess: invalidate
+ })
+}
+
+export function useRenameDpiRule() {
+ const invalidate = useDpiRulesInvalidation()
+
+ return useMutation({
+ mutationFn: (payload: { id: string; name: string }) =>
+ ubusCall('ns.dpi', 'rename-rule', payload),
+ onSuccess: invalidate
+ })
+}
+
+export function useToggleDpiRule() {
+ const invalidate = useDpiRulesInvalidation()
+
+ return useMutation({
+ mutationFn: (payload: { id: string; enabled: boolean }) =>
+ ubusCall('ns.dpi', payload.enabled ? 'enable-rule' : 'disable-rule', {
+ id: payload.id
+ }),
+ onSuccess: invalidate
+ })
+}
+
+export function useOrderDpiRules() {
+ const invalidate = useDpiRulesInvalidation()
+
+ return useMutation({
+ mutationFn: (order: string[]) =>
+ ubusCall('ns.dpi', 'order-rules', { order }),
+ onSuccess: invalidate
+ })
+}
+
+export function reorderRuleIds(rules: DpiRule[], movedId: string, targetIndex: number): string[] {
+ const ids = rules.map((rule) => rule.id)
+ const from = ids.indexOf(movedId)
+ if (from === -1) {
+ return ids
+ }
+
+ ids.splice(from, 1)
+ // the target index refers to the list before the move
+ const to = Math.max(0, Math.min(ids.length, from < targetIndex ? targetIndex - 1 : targetIndex))
+ ids.splice(to, 0, movedId)
+ return ids
+}
+
+export function moveRuleIdToEdge(
+ rules: DpiRule[],
+ movedId: string,
+ edge: DpiRulePosition
+): string[] {
+ const ids = rules.map((rule) => rule.id).filter((id) => id !== movedId)
+ return edge === 'top' ? [movedId, ...ids] : [...ids, movedId]
+}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 5c74de9c4..2c907d71e 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -71,7 +71,10 @@
"remove": "Remove",
"connected": "Connected",
"not_connected": "Not connected",
- "port": "Port"
+ "port": "Port",
+ "create": "Create",
+ "duplicate": "Duplicate",
+ "reset_filters": "Reset filters"
},
"error": {
"generic_error": "Something went wrong",
@@ -438,7 +441,9 @@
"cannot_renew_server_cert": "Cannot renew server certificate",
"cannot_regenerate_all_certs": "Cannot regenerate all certificates",
"cannot_retrieve_flows": "Cannot retrieve flows, please try again later",
- "cannot_retrieve_ovpn_rw_connection_history": "Cannot retrieve connection history, please try again later"
+ "cannot_retrieve_ovpn_rw_connection_history": "Cannot retrieve connection history, please try again later",
+ "cannot_retrieve_application_groups": "Cannot retrieve application groups",
+ "cannot_delete_application_group": "Cannot delete application group"
},
"ne_text_input": {
"show_password": "Show password",
@@ -1689,34 +1694,61 @@
"dpi": {
"title": "DPI filter",
"rules": "Rules",
- "exceptions": "Exceptions",
- "rules_description": "Add distinct Deep Packet Inspection (DPI) rules tailored to each interface to restrict specific applications. The number of applications is restricted when the machine lacks a subscription.",
- "no_rules_found": "No rules found",
- "create_rule": "Create rule",
+ "application_groups": "Application groups",
+ "no_application_groups_configured": "No application groups configured",
+ "no_application_groups_configured_description": "Group applications, protocols, and categories to use them in DPI rules.",
+ "create_application_group": "Create application group",
+ "create_application_group_description": "Create a reusable group by selecting application and protocol categories or individual items. Categories automatically include future additions",
+ "group_name": "Group name",
+ "group_name_placeholder": "e.g. Social and VPN services",
+ "applications": "Applications",
+ "protocols": "Protocols",
+ "uncategorized": "Uncategorized",
+ "num_of_total_selected_in_category": "{selected} of {total} selected in {category}",
+ "show_category_items": "Show the contents of {category}",
+ "selected_count": "Selected ({count})",
+ "n_selected": "{count} selected",
+ "all_selected": "All selected",
+ "no_content_selected": "No content selected",
+ "no_content_selected_description": "Select at least one application, protocol, or category.",
+ "selected_app": "{name} (app)",
+ "selected_protocol": "{name} (protocol)",
+ "selected_app_category": "{name} (app category)",
+ "selected_protocol_category": "{name} (protocols category)",
+ "remove_selection": "Remove from the selection",
+ "no_rules_configured": "No DPI rules configured",
+ "no_rules_configured_description": "Add rules to allow or block traffic by risk index, SSL/TLS version, application group or domain list. Application groups and domain lists must be created first.",
+ "add_rule": "Add rule",
+ "go_to_application_group": "Go to Application groups",
+ "general": "General",
+ "general_description": "Define the rule name and status",
+ "name": "Name",
"edit_rule": "Edit rule",
- "save_rule": "Save rule",
- "enable_rule": "Enable rule",
"source": "Source",
- "choose_interface": "Choose interface",
- "source_tooltip": "Only the traffic passing through this interface will be subjected to the blocking rule.",
- "no_interfaces_available": "No interfaces available",
- "apps_and_protocols": "Apps and protocols to block",
- "apps_and_protocols_placeholder": "Search {num} apps, protocols and categories",
- "no_apps": "No apps or protocols",
- "clear_search": "Clear search",
- "num_apps_and_protocols_selected": "{num} apps and protocols selected",
- "search_results": "Search results",
- "no_apps_or_protocols_found": "No apps or protocols found",
- "try_changing_search_filter": "Try changing your search filter",
- "available_with_subscription": "Available with subscription",
- "continue_typing_to_show_other_search_results": "Continue typing your search query to show other search results",
- "select_at_least_one_app_or_protocol": "Select at least one app or protocol",
- "type_application": "App",
- "type_protocol": "Protocol",
- "confirm_delete_rule": "You are about to delete DPI rule on interface '{iface}'",
+ "source_description": "Choose which devices, IP addresses, networks, or firewall objects this rule applies to.",
+ "source_type": "Source type",
+ "source_type_addresses": "Enter one or more addresses",
+ "source_type_any": "Any source address",
+ "source_addresses": "Source addresses",
+ "source_addresses_tooltip": "Enter one or more IPv4/IPv6 addresses, CIDR networks (e.g. 10.10.10.0/24) or IP ranges (e.g. 10.10.10.1-10.10.10.5).",
+ "source_addresses_helper": "Enter an IP address, CIDR network or IP range",
+ "source_address_placeholder": "e.g. 192.168.1.10",
+ "add_source_address": "Add source address",
+ "match_and_action": "Match and action",
+ "match_and_action_description": "Choose the traffic to match, then decide whether to allow or block it and whether to log it.",
+ "match_type": "Match type",
+ "action": "Action",
+ "action_block": "Block matching traffic",
+ "action_allow": "Allow matching traffic",
+ "block": "Block",
+ "allow": "Allow",
+ "rule_position": "Rule position",
+ "position_top": "Top of the list",
+ "position_bottom": "Bottom of the list",
+ "review": "Review",
+ "review_description": "Review the rule before creating it. Rules are evaluated from top to bottom.",
+ "confirm_delete_rule": "Rule '{name}' will be deleted.",
"delete_rule": "Delete rule",
- "blocked_apps_and_protocols": "Blocked apps and protocols",
- "plus_num_others": "+{num} others",
"delete_exception_message": "You are about to delete DPI exception '{name}'",
"delete_exception": "Delete exception",
"add_exception": "Add exception",
@@ -1725,7 +1757,59 @@
"status": "Status",
"ip_address": "IP address",
"exception_name": "Exception name",
- "no_exception_found": "No exception found"
+ "no_exception_found": "No exception found",
+ "rule_name": "Rule name",
+ "edit_application_group": "Edit application group",
+ "duplicated_group_name": "{name} (copy)",
+ "name_required": "Enter a group name",
+ "name_too_long": "The name cannot exceed 64 characters",
+ "name_already_exists": "A group with this name already exists",
+ "invalid_application": "One or more applications are not valid",
+ "invalid_application_category": "One or more application categories are not valid",
+ "invalid_protocol": "One or more protocols are not valid",
+ "invalid_protocol_category": "One or more protocol categories are not valid",
+ "appgroup_is_empty": "Select at least one application, protocol, or category",
+ "appgroup_does_not_exists": "The group no longer exists",
+ "appgroup_is_used": "The group is used by one or more rules",
+ "not_used": "Not used",
+ "no_application_groups_found": "No application groups found",
+ "no_application_groups_found_description": "No group matches the current filter. Change it or reset it to see every group.",
+ "delete_application_group": "Delete application group",
+ "confirm_delete_application_group": "'{name}' group will be deleted",
+ "cannot_delete_used_application_group": "The group {name} cannot be deleted while it is used by the following rules:",
+ "content": "Content",
+ "used_by_rules": "Used by rules",
+ "used_by_n_rules": "{count} rule | {count} rules",
+ "n_other_rules": "+{count} more",
+ "n_app_categories": "{count} app category | {count} app categories",
+ "n_protocol_categories": "{count} protocol category | {count} protocol categories",
+ "n_applications": "{count} application | {count} applications",
+ "n_protocols": "{count} protocol | {count} protocols",
+ "filter_groups": "Filter groups",
+ "match": "Match",
+ "priority": "Priority",
+ "criteria": "Criteria",
+ "custom": "Custom",
+ "rename_rule": "Rename rule",
+ "move_to_top": "Move to top",
+ "move_to_bottom": "Move to bottom",
+ "duplicated_rule_name": "{name} (copy)",
+ "choose_application_group": "Choose application group",
+ "filter_rules": "Filter rules",
+ "filter_by_source": "Filter by source",
+ "clear_filters_to_sort_rules": "Clear the filters to reorder the rules",
+ "rules_evaluation_order": "Rules are evaluated from top to bottom and the first match wins: a rule never applies if one above it already matched the traffic.",
+ "no_rules_found": "No rules found",
+ "no_rules_found_description": "No rule matches the current filters. Change them or reset them to see every rule.",
+ "appgroups_required": "Select at least one application group",
+ "invalid_action": "The action is not valid",
+ "invalid_position": "The position is not valid",
+ "invalid_source": "One or more source addresses are not valid",
+ "rule_not_found": "The rule no longer exists",
+ "rule_not_managed": "This rule was not created here and cannot be edited",
+ "invalid_order": "The new order does not cover every rule",
+ "show_only_selectable_items": "Show only selectable items",
+ "show_all_items": "Show all items"
},
"threat_shield": {
"title": "Threat shield IP",
diff --git a/src/i18n/it.json b/src/i18n/it.json
index 8dfc30850..120a33ff8 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -71,7 +71,10 @@
"remove": "Rimuovi",
"connected": "Connesso",
"not_connected": "Non connesso",
- "port": "Porta"
+ "port": "Porta",
+ "create": "Crea",
+ "duplicate": "Duplica",
+ "reset_filters": "Azzera filtri"
},
"error": {
"generic_error": "Qualcosa è andato storto",
@@ -438,7 +441,9 @@
"cannot_renew_server_cert": "Impossibile rinnovare il certificato del server",
"cannot_regenerate_all_certs": "Impossibile rigenerare tutti i certificati",
"cannot_retrieve_flows": "Impossibile recuperare i flussi, si prega di riprovare più tardi",
- "cannot_retrieve_ovpn_rw_connection_history": "Impossibile recuperare la cronologia delle connessioni, riprova più tardi"
+ "cannot_retrieve_ovpn_rw_connection_history": "Impossibile recuperare la cronologia delle connessioni, riprova più tardi",
+ "cannot_retrieve_application_groups": "Impossibile recuperare i gruppi di applicazioni",
+ "cannot_delete_application_group": "Impossibile eliminare il gruppo di applicazioni"
},
"login": {
"sign_in": "Accedi",
@@ -1388,36 +1393,63 @@
"description": "Connettere l'unità a un'istanza di hotspot manager remota."
},
"dpi": {
- "search_results": "Risultati della ricerca",
- "create_rule": "Crea regola",
- "clear_search": "Svuota ricerca",
+ "add_rule": "Aggiungi regola",
"delete_rule": "Elimina regola",
- "try_changing_search_filter": "Prova a cambiare il filtro di ricerca",
- "exceptions": "Eccezioni",
+ "application_groups": "Gruppi di applicazioni",
+ "no_application_groups_configured": "Nessun gruppo di applicazioni configurato",
+ "no_application_groups_configured_description": "Raggruppa applicazioni, protocolli e categorie per usarli nelle regole DPI.",
+ "create_application_group": "Crea gruppo di applicazioni",
+ "create_application_group_description": "Crea un gruppo riutilizzabile selezionando categorie di applicazioni e protocolli oppure singoli elementi. Le categorie includono automaticamente le aggiunte future",
+ "group_name": "Nome del gruppo",
+ "group_name_placeholder": "es. Social e servizi VPN",
+ "applications": "Applicazioni",
+ "protocols": "Protocolli",
+ "uncategorized": "Senza categoria",
+ "num_of_total_selected_in_category": "{selected} di {total} selezionati in {category}",
+ "show_category_items": "Mostra il contenuto di {category}",
+ "selected_count": "Selezionati ({count})",
+ "n_selected": "{count} selezionati",
+ "all_selected": "Tutti selezionati",
+ "no_content_selected": "Nessun contenuto selezionato",
+ "no_content_selected_description": "Seleziona almeno un’applicazione, un protocollo o una categoria.",
+ "selected_app": "{name} (app)",
+ "selected_protocol": "{name} (protocollo)",
+ "selected_app_category": "{name} (categoria app)",
+ "selected_protocol_category": "{name} (categoria protocolli)",
+ "remove_selection": "Rimuovi dalla selezione",
"source": "Sorgente",
- "type_protocol": "Protocollo",
- "type_application": "App",
+ "source_description": "Scegli a quali dispositivi, indirizzi IP, reti o oggetti firewall si applica questa regola.",
"rules": "Regole",
- "no_rules_found": "Nessuna regola trovata",
- "choose_interface": "Scegli interfaccia",
- "no_apps_or_protocols_found": "Nessuna app o protocollo trovati",
- "confirm_delete_rule": "Stai per eliminare la regola DPI sull'interfaccia '{iface}'",
- "source_tooltip": "Solo il traffico che passa attraverso questa interfaccia sarà soggetta al blocco.",
- "num_apps_and_protocols_selected": "{num} app e protocolli selezionati",
+ "no_rules_configured": "Nessuna regola DPI configurata",
+ "no_rules_configured_description": "Aggiungi regole per consentire o bloccare il traffico in base all'indice di rischio, alla versione SSL/TLS, al gruppo di applicazioni o alla lista di domini. I gruppi di applicazioni e le liste di domini devono essere creati prima.",
+ "go_to_application_group": "Vai a Gruppi di applicazioni",
+ "general": "Generale",
+ "general_description": "Definisci il nome e lo stato della regola",
+ "name": "Nome",
+ "confirm_delete_rule": "La regola '{name}' sarà eliminata.",
+ "source_type": "Tipo di sorgente",
+ "source_type_addresses": "Inserisci uno o più indirizzi",
+ "source_type_any": "Qualsiasi indirizzo sorgente",
+ "source_addresses": "Indirizzi sorgente",
+ "source_addresses_tooltip": "Inserisci uno o più indirizzi IPv4/IPv6, reti CIDR (es. 10.10.10.0/24) o intervalli di IP (es. 10.10.10.1-10.10.10.5).",
+ "source_addresses_helper": "Inserisci un indirizzo IP, una rete CIDR o un intervallo di IP",
+ "source_address_placeholder": "es. 192.168.1.10",
+ "add_source_address": "Aggiungi indirizzo sorgente",
+ "match_and_action": "Corrispondenza e azione",
+ "match_and_action_description": "Scegli il traffico da intercettare, poi decidi se consentirlo o bloccarlo e se registrarlo nel log.",
+ "match_type": "Tipo di corrispondenza",
+ "action": "Azione",
+ "action_block": "Blocca il traffico corrispondente",
+ "action_allow": "Consenti il traffico corrispondente",
+ "block": "Blocca",
+ "allow": "Consenti",
+ "rule_position": "Posizione della regola",
+ "position_top": "Inizio dell’elenco",
+ "position_bottom": "Fine dell’elenco",
+ "review": "Riepilogo",
+ "review_description": "Controlla la regola prima di crearla. Le regole vengono valutate dall’alto verso il basso.",
"edit_rule": "Modifica regola",
- "save_rule": "Salva la regola",
- "apps_and_protocols": "App e protocolli da bloccare",
"title": "Filtro DPI",
- "apps_and_protocols_placeholder": "Ricerca {num} app, protocolli e categorie",
- "plus_num_others": "+{num} altri",
- "enable_rule": "Abilita regola",
- "rules_description": "Aggiungi regole di Deep Packet Inspection (DPI) per limitare specifiche applicazioni e protocolli su qualunque interfaccia. Il numero di applicazioni supportate è limitato se la unità non ha una subscription attiva.",
- "select_at_least_one_app_or_protocol": "Seleziona almeno un'app o un protocollo",
- "blocked_apps_and_protocols": "App e protocolli bloccati",
- "available_with_subscription": "Disponibile con subscription",
- "continue_typing_to_show_other_search_results": "Continua a digitare la query di ricerca per mostrare altri risultati",
- "no_apps": "Nessuna app o protocollo",
- "no_interfaces_available": "Nessuna interfaccia disponibile",
"ip_address": "Indirizzo IP",
"add_exception": "Aggiungi eccezione",
"status": "Stato",
@@ -1426,7 +1458,59 @@
"delete_exception_message": "Stai per cancellare l'eccezione DPI '{name}'",
"exceptions_description": "Applica una esclusione DPI per escludere alcuni indirizzi nella rete, come ad esempio il gateway o altre importanti infrastrutture, dall'essere bloccate.",
"no_exception_found": "Nessuna eccezione trovata",
- "edit_exception": "Modifica eccezione"
+ "edit_exception": "Modifica eccezione",
+ "rule_name": "Nome regola",
+ "edit_application_group": "Modifica gruppo di applicazioni",
+ "duplicated_group_name": "{name} (copia)",
+ "name_required": "Inserisci un nome per il gruppo",
+ "name_too_long": "Il nome non può superare i 64 caratteri",
+ "name_already_exists": "Esiste già un gruppo con questo nome",
+ "invalid_application": "Una o più applicazioni non sono valide",
+ "invalid_application_category": "Una o più categorie di applicazioni non sono valide",
+ "invalid_protocol": "Uno o più protocolli non sono validi",
+ "invalid_protocol_category": "Una o più categorie di protocolli non sono valide",
+ "appgroup_is_empty": "Seleziona almeno un’applicazione, un protocollo o una categoria",
+ "appgroup_does_not_exists": "Il gruppo non esiste più",
+ "appgroup_is_used": "Il gruppo è utilizzato da una o più regole",
+ "not_used": "Non utilizzato",
+ "no_application_groups_found": "Nessun gruppo di applicazioni trovato",
+ "no_application_groups_found_description": "Nessun gruppo corrisponde al filtro attuale. Modificalo o azzeralo per vedere tutti i gruppi.",
+ "delete_application_group": "Elimina gruppo di applicazioni",
+ "confirm_delete_application_group": "Il gruppo '{name}' sarà eliminato",
+ "cannot_delete_used_application_group": "Il gruppo {name} non può essere eliminato perché è utilizzato dalle seguenti regole:",
+ "content": "Contenuto",
+ "used_by_rules": "Usato da regole",
+ "used_by_n_rules": "{count} regola | {count} regole",
+ "n_other_rules": "+{count} altri",
+ "n_app_categories": "{count} categoria di app | {count} categorie di app",
+ "n_protocol_categories": "{count} categoria di protocolli | {count} categorie di protocolli",
+ "n_applications": "{count} applicazione | {count} applicazioni",
+ "n_protocols": "{count} protocollo | {count} protocolli",
+ "filter_groups": "Filtra gruppi",
+ "match": "Corrispondenza",
+ "priority": "Priorità",
+ "criteria": "Criteri",
+ "custom": "Personalizzata",
+ "rename_rule": "Rinomina regola",
+ "move_to_top": "Sposta all’inizio",
+ "move_to_bottom": "Sposta alla fine",
+ "duplicated_rule_name": "{name} (copia)",
+ "choose_application_group": "Scegli gruppo di applicazioni",
+ "filter_rules": "Filtra regole",
+ "filter_by_source": "Filtra per sorgente",
+ "clear_filters_to_sort_rules": "Azzera i filtri per riordinare le regole",
+ "rules_evaluation_order": "Le regole vengono valutate dall’alto verso il basso e vince la prima corrispondenza: una regola non si applica se una precedente ha già intercettato il traffico.",
+ "no_rules_found": "Nessuna regola trovata",
+ "no_rules_found_description": "Nessuna regola corrisponde ai filtri attuali. Modificali o azzerali per vedere tutte le regole.",
+ "appgroups_required": "Seleziona almeno un gruppo di applicazioni",
+ "invalid_action": "L’azione non è valida",
+ "invalid_position": "La posizione non è valida",
+ "invalid_source": "Uno o più indirizzi sorgente non sono validi",
+ "rule_not_found": "La regola non esiste più",
+ "rule_not_managed": "Questa regola non è stata creata qui e non può essere modificata",
+ "invalid_order": "Il nuovo ordine non copre tutte le regole",
+ "show_only_selectable_items": "Mostra solo gli elementi selezionabili",
+ "show_all_items": "Mostra tutti gli elementi"
},
"openvpn_tunnel": {
"auto": "Automatico",
diff --git a/src/lib/standalone/dpi.ts b/src/lib/standalone/dpi.ts
deleted file mode 100644
index 97b446b4e..000000000
--- a/src/lib/standalone/dpi.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2024 Nethesis S.r.l.
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-import { upperFirst } from 'lodash-es'
-
-export interface DpiAppOrProtocol {
- id: number
- name: string
- type: 'application' | 'protocol'
- missing?: boolean
- category?: {
- name: string
- }
-}
-
-export interface DpiRule {
- 'config-name': string
- enabled: boolean
- device: string
- interface: string
- criteria: DpiAppOrProtocol[]
-}
-
-export function getHumanizedAppName(appName: string) {
- // capitalize, remove 'netify.' prefix and replace dashes with spaces
- return upperFirst(appName.replace(/-/g, ' ').replace(/netify./g, ''))
-}
-
-export function getHumanizedCategoryName(categoryName: string) {
- // capitalize and replace dashes with spaces
- return upperFirst(categoryName.replace(/-/g, ' '))
-}
-
-export function getAppIcon(app: DpiAppOrProtocol) {
- switch (app.name) {
- case 'netify.facebook':
- return ['fab', 'facebook']
- case 'netify.amazon-prime':
- return ['fab', 'amazon']
- case 'netify.whatsapp':
- return ['fab', 'whatsapp']
- case 'netify.instagram':
- return ['fab', 'instagram']
- case 'netify.netflix':
- return ['fab', 'netflix']
- case 'netify.telegram':
- return ['fab', 'telegram']
- case 'netify.tiktok':
- return ['fab', 'tiktok']
- case 'netify.youtube':
- return ['fab', 'youtube']
- case 'netify.facebook-messenger':
- return ['fab', 'facebook-messenger']
- case 'netify.twitter':
- return ['fab', 'square-x-twitter']
- case 'netify.vimeo':
- return ['fab', 'vimeo']
- case 'netify.snapchat':
- return ['fab', 'snapchat']
- case 'netify.pinterest':
- return ['fab', 'pinterest']
- case 'netify.nordvpn':
- return ['fab', 'nord-vpn']
- case 'netify.twitch':
- return ['fab', 'twitch']
- case 'netify.teamviewer':
- return ['fas', 'arrows-left-right']
- default:
- if (app.type === 'application') {
- return ['fas', 'shapes']
- } else {
- return ['fas', 'diagram-project']
- }
- }
-}
diff --git a/src/stores/standalone/netifyd.ts b/src/stores/standalone/netifyd.ts
index 9051c3203..deb7dd9bc 100644
--- a/src/stores/standalone/netifyd.ts
+++ b/src/stores/standalone/netifyd.ts
@@ -9,17 +9,26 @@ export type Response = {
}
}
+export type CatalogCategory = {
+ id: number
+ label: string
+ tag: string
+}
+
export type Application = {
id: number
tag: string
label: string
icon?: string
+ category?: CatalogCategory
+ active?: boolean
}
export type Protocol = {
id: number
tag: string
label: string
+ category?: CatalogCategory
}
export const useNetifydStore = defineStore('netifyd', () => {
diff --git a/src/stores/standalone/subscription.ts b/src/stores/standalone/subscription.ts
index 832babc93..59afb8cfa 100644
--- a/src/stores/standalone/subscription.ts
+++ b/src/stores/standalone/subscription.ts
@@ -1,6 +1,11 @@
import { defineStore } from 'pinia'
import { onMounted, ref } from 'vue'
+import { useQueryClient } from '@tanstack/vue-query'
import { ubusCall } from '@/lib/standalone/ubus.ts'
+import {
+ DPI_LOADED_APPLICATIONS_KEY,
+ DPI_LOADED_PROTOCOLS_KEY
+} from '@/composables/useDpiCatalog.ts'
import type { AxiosResponse } from 'axios'
type SubscriptionStatusResponse = AxiosResponse
@@ -14,6 +19,7 @@ export type SubscriptionDataType = {
}
export const useSubscriptionStore = defineStore('subscription', () => {
+ const queryClient = useQueryClient()
const loading = ref(true)
const error = ref()
const isActive = ref(false)
@@ -23,7 +29,14 @@ export const useSubscriptionStore = defineStore('subscription', () => {
error.value = undefined
ubusCall('ns.subscription', 'info')
.then((res: SubscriptionStatusResponse) => {
- isActive.value = res.data.active ?? false
+ const active = res.data.active ?? false
+
+ if (active !== isActive.value) {
+ queryClient.invalidateQueries({ queryKey: DPI_LOADED_APPLICATIONS_KEY })
+ queryClient.invalidateQueries({ queryKey: DPI_LOADED_PROTOCOLS_KEY })
+ }
+
+ isActive.value = active
})
.catch((err) => {
error.value = err
diff --git a/src/views/standalone/security/DpiFilterView.vue b/src/views/standalone/security/DpiFilterView.vue
index 867984ef8..5f56f74f0 100644
--- a/src/views/standalone/security/DpiFilterView.vue
+++ b/src/views/standalone/security/DpiFilterView.vue
@@ -7,13 +7,13 @@
import { NeHeading, NeTabs } from '@nethesis/vue-components'
import { useI18n } from 'vue-i18n'
import DpiRules from '@/components/standalone/dpi/DpiRules.vue'
-import DpiExceptions from '@/components/standalone/dpi/DpiExceptions.vue'
+import ApplicationGroups from '@/components/standalone/dpi/ApplicationGroups.vue'
import { useTabs } from '@/composables/useTabs'
const { t } = useI18n()
const { tabs, selectedTab } = useTabs([
{ name: 'rules', label: t('standalone.dpi.rules') },
- { name: 'exceptions', label: t('standalone.dpi.exceptions') }
+ { name: 'application-groups', label: t('standalone.dpi.application_groups') }
])
@@ -32,8 +32,8 @@ const { tabs, selectedTab } = useTabs([
-
-
+
+