- {{ rule.port ? `${rule.protocol}:${rule.port}` : rule.protocol }}
+ {{ protocolLabel }}
{{ direction.labelA }} {{ rule.ipRange }} {{ direction.labelB }}
{
return { labelA, labelB }
})
+const protocolLabel = computed(() => getTrafficRuleProtocolLabel(rule))
+
const description = computed(() => {
const policyLabel = rule.allow ? t('allow') : t('drop')
- const protocol = rule.port ? `${rule.protocol}:${rule.port}` : rule.protocol
const { label, suffix } = target.value
- const descriptionParts = [policyLabel, protocol, direction.value.labelA, rule.ipRange, direction.value.labelB]
+ const descriptionParts = [
+ policyLabel,
+ protocolLabel.value,
+ direction.value.labelA,
+ rule.ipRange,
+ direction.value.labelB,
+ ]
if (label) {
descriptionParts.push(label)
diff --git a/@xen-orchestra/web/src/modules/traffic-rules/composables/traffic-rules.composable.ts b/@xen-orchestra/web/src/modules/traffic-rules/composables/traffic-rules.composable.ts
index d5c39bdae4..10d74ba7da 100644
--- a/@xen-orchestra/web/src/modules/traffic-rules/composables/traffic-rules.composable.ts
+++ b/@xen-orchestra/web/src/modules/traffic-rules/composables/traffic-rules.composable.ts
@@ -1,4 +1,9 @@
-import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
+import {
+ type FrontXoNetwork,
+ useXoNetworkCollection,
+} from '@/modules/network/remote-resources/use-xo-network-collection.ts'
+import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
+import { rankTrafficRules } from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import type { FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
@@ -22,12 +27,10 @@ export function useTrafficRules(
const { getVmById } = useXoVmCollection()
- const trafficRules = computed(() => {
- const sortedNetworks = [...networks.value].sort((networkA, networkB) =>
- (networkA.name_label ?? '').localeCompare(networkB.name_label ?? '')
- )
+ const { getNetworkById } = useXoNetworkCollection()
- const sortedVifs = [...vifs.value]
+ const trafficRules = computed(() => {
+ const sortedVifs = vifs.value
.filter(vif => getVmById(vif.$VM))
.sort((vifA, vifB) => {
const vmNameLabelA = getVmById(vifA.$VM)?.name_label ?? ''
@@ -41,8 +44,8 @@ export function useTrafficRules(
return Number(vifA.device) - Number(vifB.device)
})
- return [
- ...sortedNetworks.flatMap(network =>
+ const rules: TrafficRule[] = [
+ ...networks.value.flatMap(network =>
parseRules(network.other_config?.[SDN_CONTROLLER_OF_RULES_KEY]).map((rule, index) => ({
...rule,
id: `network:${network.id}:${index}`,
@@ -61,6 +64,8 @@ export function useTrafficRules(
}))
),
]
+
+ return rankTrafficRules(rules, networkId => getNetworkById(networkId)?.name_label ?? '')
})
return { trafficRules }
diff --git a/@xen-orchestra/web/src/modules/traffic-rules/jobs/xo-traffic-rule-delete.job.ts b/@xen-orchestra/web/src/modules/traffic-rules/jobs/xo-traffic-rule-delete.job.ts
index bd048720c6..b6361b2709 100644
--- a/@xen-orchestra/web/src/modules/traffic-rules/jobs/xo-traffic-rule-delete.job.ts
+++ b/@xen-orchestra/web/src/modules/traffic-rules/jobs/xo-traffic-rule-delete.job.ts
@@ -29,6 +29,10 @@ export const useXoTrafficRuleDeleteJob = defineJob('traffic-rule.delete', [xoTra
body.port = rule.port
}
+ if (rule.priority !== undefined) {
+ body.priority = rule.priority
+ }
+
if (rule.type === 'VIF') {
body.vifId = rule.sourceId
} else {
diff --git a/@xen-orchestra/web/src/modules/traffic-rules/jobs/xo-traffic-rule-edit.job.ts b/@xen-orchestra/web/src/modules/traffic-rules/jobs/xo-traffic-rule-edit.job.ts
index 6b3291d718..f54724208a 100644
--- a/@xen-orchestra/web/src/modules/traffic-rules/jobs/xo-traffic-rule-edit.job.ts
+++ b/@xen-orchestra/web/src/modules/traffic-rules/jobs/xo-traffic-rule-edit.job.ts
@@ -29,6 +29,7 @@ export const useXoTrafficRuleEditJob = defineJob(
ipRange: oldRule.ipRange,
protocol: oldRule.protocol,
port: oldRule.port !== undefined ? Number(oldRule.port) : undefined,
+ priority: oldRule.priority,
},
newRule,
})
diff --git a/@xen-orchestra/web/src/modules/traffic-rules/types.ts b/@xen-orchestra/web/src/modules/traffic-rules/types.ts
index e3abae54da..18447be79b 100644
--- a/@xen-orchestra/web/src/modules/traffic-rules/types.ts
+++ b/@xen-orchestra/web/src/modules/traffic-rules/types.ts
@@ -1,7 +1,12 @@
import type { TrafficRule } from '@vates/types'
-export type EnrichedTrafficRule = TrafficRule & {
- order: number
+export type RankedTrafficRule = TrafficRule & {
+ rank: number
+}
+
+export type EnrichedTrafficRule = RankedTrafficRule & {
+ networkLabel: string
+ networkRuleCount: number
directionA: string
directionB: string
directionLabel: string
diff --git a/@xen-orchestra/web/src/modules/traffic-rules/utils/xo-traffic-rule.util.ts b/@xen-orchestra/web/src/modules/traffic-rules/utils/xo-traffic-rule.util.ts
index af1d5761bd..ec25137305 100644
--- a/@xen-orchestra/web/src/modules/traffic-rules/utils/xo-traffic-rule.util.ts
+++ b/@xen-orchestra/web/src/modules/traffic-rules/utils/xo-traffic-rule.util.ts
@@ -1,8 +1,81 @@
+import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
-import { SDN_CONTROLLER_OF_METHOD_KEY } from '@vates/types'
+import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
+import { SDN_CONTROLLER_OF_METHOD_KEY, type TrafficRule } from '@vates/types'
+
+// OpenFlow default, applied to rules stored without a priority
+const DEFAULT_TRAFFIC_RULE_PRIORITY = 32768
export function isNetworkRuleSupported(pool: FrontXoPool | undefined): boolean {
const ofMethod = pool?.otherConfig[SDN_CONTROLLER_OF_METHOD_KEY]
return ofMethod === 'xapi-plugin'
}
+
+export function getTrafficRulePriority(rule: TrafficRule): number {
+ if (rule.priority === undefined) {
+ return DEFAULT_TRAFFIC_RULE_PRIORITY
+ }
+
+ return rule.priority
+}
+
+export function getTrafficRuleProtocolLabel(rule: TrafficRule): string {
+ if (rule.port === undefined) {
+ return rule.protocol
+ }
+
+ return `${rule.protocol}:${rule.port}`
+}
+
+export function getNetworkRules(rules: TRule[], networkId: FrontXoNetwork['id']): TRule[] {
+ return rules.filter(rule => rule.networkId === networkId)
+}
+
+export function rankTrafficRules(
+ rules: TrafficRule[],
+ getNetworkLabel: (networkId: FrontXoNetwork['id']) => string
+): RankedTrafficRule[] {
+ const sortedRules = [...rules].sort((ruleA, ruleB) => {
+ const networkComparison = getNetworkLabel(ruleA.networkId).localeCompare(getNetworkLabel(ruleB.networkId))
+
+ if (networkComparison !== 0) {
+ return networkComparison
+ }
+
+ if (ruleA.networkId !== ruleB.networkId) {
+ return ruleA.networkId.localeCompare(ruleB.networkId)
+ }
+
+ return getTrafficRulePriority(ruleB) - getTrafficRulePriority(ruleA)
+ })
+
+ const lastRankByNetworkId = new Map()
+
+ return sortedRules.map(rule => {
+ const lastRank = lastRankByNetworkId.get(rule.networkId)
+
+ let rank = 1
+
+ if (lastRank !== undefined) {
+ rank = lastRank + 1
+ }
+
+ lastRankByNetworkId.set(rule.networkId, rank)
+
+ return { ...rule, rank }
+ })
+}
+
+export function getOrdinalSuffix(suffixesMessage: string, locale: string, position: number): string | undefined {
+ const suffixes = new Map()
+
+ for (const entry of suffixesMessage.split(',')) {
+ const [category, suffix] = entry.trim().split(':')
+ suffixes.set(category, suffix)
+ }
+
+ const category = new Intl.PluralRules(locale, { type: 'ordinal' }).select(position)
+
+ return suffixes.get(category)
+}
diff --git a/@xen-orchestra/web/src/modules/traffic-rules/utils/xo-traffic-rule.util.unit.test.ts b/@xen-orchestra/web/src/modules/traffic-rules/utils/xo-traffic-rule.util.unit.test.ts
new file mode 100644
index 0000000000..616eda5100
--- /dev/null
+++ b/@xen-orchestra/web/src/modules/traffic-rules/utils/xo-traffic-rule.util.unit.test.ts
@@ -0,0 +1,167 @@
+import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
+import {
+ getNetworkRules,
+ getOrdinalSuffix,
+ getTrafficRulePriority,
+ getTrafficRuleProtocolLabel,
+ rankTrafficRules,
+} from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
+import type { FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
+import { createTrafficRule } from '@/test/create-traffic-rule.ts'
+
+const EN_SUFFIXES = 'one:st, two:nd, few:rd, other:th'
+const FR_SUFFIXES = 'one:re, other:e'
+
+describe('getTrafficRulePriority', () => {
+ it('returns the priority of the rule', () => {
+ expect(getTrafficRulePriority(createTrafficRule({ priority: 65000 }))).toBe(65000)
+ })
+
+ it('keeps a priority of 0', () => {
+ expect(getTrafficRulePriority(createTrafficRule({ priority: 0 }))).toBe(0)
+ })
+
+ it('defaults to the OpenFlow priority when the rule has none', () => {
+ expect(getTrafficRulePriority(createTrafficRule({ priority: undefined }))).toBe(32768)
+ })
+})
+
+describe('getTrafficRuleProtocolLabel', () => {
+ it('returns the protocol alone when the rule has no port', () => {
+ expect(getTrafficRuleProtocolLabel(createTrafficRule({ protocol: 'ICMP', port: undefined }))).toBe('ICMP')
+ })
+
+ it('appends the port to the protocol', () => {
+ expect(getTrafficRuleProtocolLabel(createTrafficRule({ protocol: 'TCP', port: '443' }))).toBe('TCP:443')
+ })
+})
+
+describe('getNetworkRules', () => {
+ const networkA = 'network-a' as FrontXoNetwork['id']
+ const networkB = 'network-b' as FrontXoNetwork['id']
+
+ const ruleA1 = createTrafficRule({ id: 'a1', networkId: networkA })
+ const ruleB1 = createTrafficRule({ id: 'b1', networkId: networkB })
+ const ruleA2 = createTrafficRule({ id: 'a2', networkId: networkA })
+
+ it('keeps only the rules of the given network, in their order', () => {
+ expect(getNetworkRules([ruleA1, ruleB1, ruleA2], networkA)).toEqual([ruleA1, ruleA2])
+ })
+
+ it('returns an empty list when the network has no rule', () => {
+ expect(getNetworkRules([ruleA1, ruleA2], networkB)).toEqual([])
+ })
+})
+
+describe('rankTrafficRules', () => {
+ const lanNetwork = 'network-1' as FrontXoNetwork['id']
+ const dmzNetwork = 'network-2' as FrontXoNetwork['id']
+ const otherLanNetwork = 'network-3' as FrontXoNetwork['id']
+
+ const networkLabels = new Map([
+ [lanNetwork, 'LAN'],
+ [dmzNetwork, 'DMZ'],
+ [otherLanNetwork, 'LAN'],
+ ])
+
+ const getNetworkLabel = (networkId: FrontXoNetwork['id']) => networkLabels.get(networkId) ?? ''
+
+ it('sorts by descending priority within a network', () => {
+ const low = createTrafficRule({ id: 'low', priority: 100 })
+ const high = createTrafficRule({ id: 'high', priority: 200 })
+
+ expect(rankTrafficRules([low, high], getNetworkLabel).map(rule => rule.id)).toEqual(['high', 'low'])
+ })
+
+ it('places rules without priority at the default priority', () => {
+ const above = createTrafficRule({ id: 'above', priority: 40000 })
+ const none = createTrafficRule({ id: 'none', priority: undefined })
+ const below = createTrafficRule({ id: 'below', priority: 100 })
+
+ expect(rankTrafficRules([below, none, above], getNetworkLabel).map(rule => rule.id)).toEqual([
+ 'above',
+ 'none',
+ 'below',
+ ])
+ })
+
+ it('keeps input order for rules with equal priority', () => {
+ const networkRule = createTrafficRule({ id: 'network' })
+ const vifRuleA = createTrafficRule({ id: 'vif-a', type: 'VIF', sourceId: 'vif-a' as FrontXoVif['id'] })
+ const vifRuleB = createTrafficRule({ id: 'vif-b', type: 'VIF', sourceId: 'vif-b' as FrontXoVif['id'] })
+
+ expect(rankTrafficRules([networkRule, vifRuleA, vifRuleB], getNetworkLabel).map(rule => rule.id)).toEqual([
+ 'network',
+ 'vif-a',
+ 'vif-b',
+ ])
+ })
+
+ it('gives distinct ranks to rules with equal priority', () => {
+ const rules = [createTrafficRule({ id: 'a' }), createTrafficRule({ id: 'b' })]
+
+ expect(rankTrafficRules(rules, getNetworkLabel).map(rule => rule.rank)).toEqual([1, 2])
+ })
+
+ it('groups rules by network label and restarts ranks per network', () => {
+ const lan = createTrafficRule({ id: 'lan', networkId: lanNetwork })
+ const dmz = createTrafficRule({ id: 'dmz', networkId: dmzNetwork })
+
+ expect(rankTrafficRules([lan, dmz], getNetworkLabel).map(rule => ({ id: rule.id, rank: rule.rank }))).toEqual([
+ { id: 'dmz', rank: 1 },
+ { id: 'lan', rank: 1 },
+ ])
+ })
+
+ it('does not interleave two networks with the same label', () => {
+ const rules = [
+ createTrafficRule({ id: 'n1-low', networkId: lanNetwork, priority: 100 }),
+ createTrafficRule({ id: 'n3-high', networkId: otherLanNetwork, priority: 300 }),
+ createTrafficRule({ id: 'n1-high', networkId: lanNetwork, priority: 200 }),
+ ]
+
+ expect(rankTrafficRules(rules, getNetworkLabel).map(rule => rule.id)).toEqual(['n1-high', 'n1-low', 'n3-high'])
+ })
+
+ it('does not mutate the input array', () => {
+ const rules = [createTrafficRule({ id: 'low', priority: 100 }), createTrafficRule({ id: 'high', priority: 200 })]
+
+ rankTrafficRules(rules, getNetworkLabel)
+
+ expect(rules.map(rule => rule.id)).toEqual(['low', 'high'])
+ })
+})
+
+describe('getOrdinalSuffix', () => {
+ it.each([
+ [1, 'st'],
+ [2, 'nd'],
+ [3, 'rd'],
+ [4, 'th'],
+ [11, 'th'],
+ [12, 'th'],
+ [13, 'th'],
+ [21, 'st'],
+ [22, 'nd'],
+ [23, 'rd'],
+ [101, 'st'],
+ ])('returns the English suffix of %i', (position, suffix) => {
+ expect(getOrdinalSuffix(EN_SUFFIXES, 'en', position)).toBe(suffix)
+ })
+
+ it.each([
+ [1, 're'],
+ [2, 'e'],
+ [21, 'e'],
+ ])('returns the French suffix of %i', (position, suffix) => {
+ expect(getOrdinalSuffix(FR_SUFFIXES, 'fr', position)).toBe(suffix)
+ })
+
+ it('ignores the spaces around the entries', () => {
+ expect(getOrdinalSuffix(' one:st ,other:th ', 'en', 1)).toBe('st')
+ })
+
+ it('returns undefined when the message has no suffix for the plural category', () => {
+ expect(getOrdinalSuffix('other:th', 'en', 1)).toBeUndefined()
+ })
+})
diff --git a/@xen-orchestra/web/src/pages/pool/[id]/traffic-rules.vue b/@xen-orchestra/web/src/pages/pool/[id]/traffic-rules.vue
index 17759c91e0..a36c469308 100644
--- a/@xen-orchestra/web/src/pages/pool/[id]/traffic-rules.vue
+++ b/@xen-orchestra/web/src/pages/pool/[id]/traffic-rules.vue
@@ -2,7 +2,7 @@
-
+
{{ t('new') }}
@@ -10,7 +10,7 @@
-
+
@@ -20,6 +20,7 @@ import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-co
import TrafficRulesSidePanel from '@/modules/traffic-rules/components/list/panel/TrafficRulesSidePanel.vue'
import TrafficRulesTable from '@/modules/traffic-rules/components/TrafficRulesTable.vue'
import { useTrafficRules } from '@/modules/traffic-rules/composables/traffic-rules.composable.ts'
+import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
import { isNetworkRuleSupported } from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import { useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
@@ -28,7 +29,6 @@ import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { useRouteQuery } from '@core/composables/route-query.composable.ts'
-import type { TrafficRule } from '@vates/types'
import { logicAnd } from '@vueuse/math'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -55,7 +55,7 @@ const poolNetworks = computed(() => networks.value.filter(network => network.$po
const { trafficRules } = useTrafficRules(poolVifs, poolNetworks)
-const selectedRule = useRouteQuery('id', {
+const selectedRule = useRouteQuery('id', {
toData: id => trafficRules.value.find(rule => rule.id === id),
toQuery: rule => rule?.id ?? '',
})
diff --git a/@xen-orchestra/web/src/pages/vif/[id]/traffic-rules.vue b/@xen-orchestra/web/src/pages/vif/[id]/traffic-rules.vue
index 3b132577e6..0fe8facfa7 100644
--- a/@xen-orchestra/web/src/pages/vif/[id]/traffic-rules.vue
+++ b/@xen-orchestra/web/src/pages/vif/[id]/traffic-rules.vue
@@ -10,7 +10,7 @@
-
+
@@ -19,6 +19,7 @@ import { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool
import TrafficRulesSidePanel from '@/modules/traffic-rules/components/list/panel/TrafficRulesSidePanel.vue'
import TrafficRulesTable from '@/modules/traffic-rules/components/TrafficRulesTable.vue'
import { useTrafficRules } from '@/modules/traffic-rules/composables/traffic-rules.composable.ts'
+import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
import { type FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import VtsContentSidePanel from '@core/components/layout/VtsContentSidePanel.vue'
@@ -26,7 +27,6 @@ import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { useRouteQuery } from '@core/composables/route-query.composable.ts'
-import type { TrafficRule } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { vif } = defineProps<{ vif: FrontXoVif }>()
@@ -40,7 +40,7 @@ const { areVmsReady } = useXoVmCollection()
const { trafficRules } = useTrafficRules(() => [vif], [])
-const selectedRule = useRouteQuery('id', {
+const selectedRule = useRouteQuery('id', {
toData: id => trafficRules.value.find(rule => rule.id === id),
toQuery: rule => rule?.id ?? '',
})
diff --git a/@xen-orchestra/web/src/test/create-traffic-rule.ts b/@xen-orchestra/web/src/test/create-traffic-rule.ts
new file mode 100644
index 0000000000..62b3aac6f8
--- /dev/null
+++ b/@xen-orchestra/web/src/test/create-traffic-rule.ts
@@ -0,0 +1,19 @@
+import type { TrafficRule, XoNetwork } from '@vates/types'
+
+/**
+ * Builds a fully-populated network `TrafficRule` for use in tests. Pass `overrides` to
+ * tweak only the fields relevant to the case under test.
+ */
+export function createTrafficRule(overrides: Partial = {}): TrafficRule {
+ return {
+ id: 'network:network-1:0',
+ type: 'network',
+ sourceId: 'network-1' as XoNetwork['id'],
+ networkId: 'network-1' as XoNetwork['id'],
+ allow: true,
+ protocol: 'TCP',
+ ipRange: '192.168.1.0/24',
+ direction: 'from/to',
+ ...overrides,
+ } as TrafficRule
+}
diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md
index 1e834df945..a12fc3c409 100644
--- a/CHANGELOG.unreleased.md
+++ b/CHANGELOG.unreleased.md
@@ -15,12 +15,13 @@
- [Side panels] Values stay next to their label when they fit and move below it otherwise, fields with several values (IP addresses, report recipients…) get a copy button per value and a "Copy all" action, and rows, links and statuses are more evenly spaced and sized (PR [#10733](https://github.com/vatesfr/xen-orchestra/pull/10733))
- [Treeview] A more compact tree, so that names get more room: lower rows running edge to edge and a smaller indent per level; hovering a quick actions button no longer highlights its whole row (PR [#10727](https://github.com/vatesfr/xen-orchestra/pull/10727))
- [Pool/Network] Add network dedicated page, with General and PIFs tabs (PR [#10704](https://github.com/vatesfr/xen-orchestra/pull/10704))
+- [Pool/Traffic rules] Display traffic rules by priority within their network (PR [#10688](https://github.com/vatesfr/xen-orchestra/pull/10688))
### Bug fixes
> Users must be able to say: "I had this issue, happy to know it's fixed"
-- [XO6/Pools] Pool side panel now shows the same pool name, UUID and icon as the pools table, and keeps the pool card when the pool is disconnected or unreachable (PR [#10714](https://github.com/vatesfr/xen-orchestra/pull/10714))
+- [Pools] Pool side panel now shows the same pool name, UUID and icon as the pools table, and keeps the pool card when the pool is disconnected or unreachable (PR [#10714](https://github.com/vatesfr/xen-orchestra/pull/10714))
### Packages to release