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
60 changes: 60 additions & 0 deletions ui/src/__tests__/subscription-group-card-status.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { mount } from '@vue/test-utils'
import SubscriptionGroupCard from '../components/SubscriptionGroupCard.vue'

function makeGroup(extra = {}) {
return {
key: 'service:prov:aws',
name: 'Provisioning AWS',
kind: 'cloud',
color: '#ff7a18',
icon: () => null,
health: 0.5,
rows: [{ name: 'p1', status: 'ok', pills: [], sub: { id: 1, node: { id: 'service:prov:aws:enedis' } } }],
nodeIds: ['service:prov:aws:enedis'],
...extra,
}
}

function mountCard(group) {
return mount(SubscriptionGroupCard, {
props: { group },
global: {
stubs: { 'v-icon': true, 'v-expand-transition': true, PluginFeatures: true },
directives: { appear: {} },
},
})
}

describe('SubscriptionGroupCard — global node status', () => {
beforeEach(() => { setActivePinia(createPinia()) })

it('renders the 3-state bar from group.nodeStatus', () => {
const w = mountCard(makeGroup({ nodeStatus: { total: 10, up: 7, down: 2, noStatus: 1 } }))
expect(w.find('.nbar').exists()).toBe(true)
// The legacy health bar is replaced by the status bar.
expect(w.find('.barh').exists()).toBe(false)
// Segment widths are proportional to the totals (7/10, 1/10, 2/10).
expect(w.find('.seg.up').attributes('style')).toContain('width: 70%')
expect(w.find('.seg.nost').attributes('style')).toContain('width: 10%')
expect(w.find('.seg.down').attributes('style')).toContain('width: 20%')
expect(w.find('.ncount').text()).toBe('7/10')
})

it('emits refresh-node with the group key and node ids on button click', async () => {
const w = mountCard(makeGroup({ nodeStatus: { total: 10, up: 7, down: 2, noStatus: 1 } }))
await w.find('.nrefresh').trigger('click')
expect(w.emitted('refresh-node')).toBeTruthy()
expect(w.emitted('refresh-node')[0][0]).toEqual({
key: 'service:prov:aws',
nodeIds: ['service:prov:aws:enedis'],
})
})

it('falls back to the health bar when no nodeStatus is present', () => {
const w = mountCard(makeGroup())
expect(w.find('.nbar').exists()).toBe(false)
expect(w.find('.barh').exists()).toBe(true)
})
})
46 changes: 44 additions & 2 deletions ui/src/components/SubscriptionGroupCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,23 @@
</div>
<div class="ch-row bottom">
<div class="kind">{{ group.kind }}</div>
<span class="health">
<!-- Global node status: 3-segment bar (UP / no status / DOWN) + a
per-node recheck button. Falls back to the legacy health bar when
no aggregated status is available (demo groups, not-yet-loaded). -->
<span v-if="group.nodeStatus" class="nstatus" :title="statusTooltip">
<span class="nbar">
<i class="seg up" :style="{ width: pct(group.nodeStatus.up) + '%' }" />
<i class="seg nost" :style="{ width: pct(group.nodeStatus.noStatus) + '%' }" />
<i class="seg down" :style="{ width: pct(group.nodeStatus.down) + '%' }" />
</span>
<span class="ncount">{{ group.nodeStatus.up }}/{{ group.nodeStatus.total }}</span>
<button class="nrefresh" type="button" :class="{ spin: group.refreshing }" :disabled="group.refreshing"
:aria-label="t('home.nodeStatus.refresh')" :title="t('home.nodeStatus.refresh')"
@click.stop="$emit('refresh-node', { key: group.key, nodeIds: group.nodeIds })">
<v-icon size="15">mdi-refresh</v-icon>
</button>
</span>
<span v-else class="health">
<span class="barh"><i :style="{ width: Math.round(group.health * 100) + '%' }" /></span>
<span class="pct">{{ Math.round(group.health * 100) }}%</span>
<span class="count">{{ group.rows.length }}</span>
Expand Down Expand Up @@ -74,11 +90,22 @@ const props = defineProps({
// Collapse is controlled by the parent panel (so "collapse all" can drive it).
collapsed: { type: Boolean, default: false },
})
defineEmits(['rowmenu', 'toggle', 'row-appear'])
defineEmits(['rowmenu', 'toggle', 'row-appear', 'refresh-node'])

const t = useI18nStore().t
const expanded = ref(false)
const shownRows = computed(() => (expanded.value ? props.group.rows : props.group.rows.slice(0, 4)))

// Segment width as a percentage of the node's total subscriptions.
function pct(n) {
const total = props.group.nodeStatus?.total || 0
return total ? Math.round((n / total) * 100) : 0
}
const statusTooltip = computed(() => {
const s = props.group.nodeStatus
if (!s) return ''
return `${t('home.nodeStatus.up')} ${s.up} · ${t('home.nodeStatus.noStatus')} ${s.noStatus} · ${t('home.nodeStatus.down')} ${s.down}`
})
</script>

<style scoped>
Expand Down Expand Up @@ -111,6 +138,21 @@ const shownRows = computed(() => (expanded.value ? props.group.rows : props.grou
.barh i { display: block; height: 100%; border-radius: 5px; background: linear-gradient(90deg, var(--c), color-mix(in srgb, var(--c) 60%, white)); }
.count { font-family: var(--mono); font-size: 11px; font-weight: 700; color: color-mix(in srgb, var(--c) 65%, var(--ink)); background: var(--card); border: var(--border-w) var(--lj-border-style, solid) color-mix(in srgb, var(--c) 22%, var(--border)); border-radius: var(--radius-sm); padding: 4px 8px; white-space: nowrap; }

/* Global node status: 3-segment bar (UP / no status / DOWN) + recheck button. */
.nstatus { flex: none; display: flex; align-items: center; gap: 7px; font-size: 11px; font-weight: 700; color: var(--ink-3); }
.nbar { display: flex; width: 64px; height: 7px; border-radius: 5px; overflow: hidden; background: var(--idle); }
.nbar .seg { height: 100%; transition: width .4s cubic-bezier(.2, .7, .3, 1); }
.nbar .seg.up { background: var(--ok); }
.nbar .seg.nost { background: var(--idle); }
.nbar .seg.down { background: var(--err); }
.ncount { font-family: var(--mono); font-variant-numeric: tabular-nums; color: color-mix(in srgb, var(--ok) 70%, var(--ink)); }
.nrefresh { flex: none; width: 26px; height: 26px; border: 0; background: transparent; border-radius: var(--radius-sm); color: var(--ink-3); cursor: pointer; display: grid; place-items: center; transition: background .12s, color .12s; }
.nrefresh:hover:not(:disabled) { background: color-mix(in srgb, var(--c) 12%, var(--card)); color: var(--ink); }
.nrefresh:disabled { cursor: default; opacity: .65; }
.nrefresh.spin :deep(.v-icon) { animation: nspin .8s linear infinite; }
@keyframes nspin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .nrefresh.spin :deep(.v-icon) { animation: none; } }

/* Mini-table rows (HomeView look) carrying the per-subscription delegation. */
.mini { padding: 6px 10px 10px; max-height: 360px; overflow-y: auto; }
.mrow { position: relative; display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 11px; transition: background .12s; }
Expand Down
4 changes: 2 additions & 2 deletions ui/src/components/SubscriptionsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
<div v-if="view === 'cards'" class="sp-grid">
<SubscriptionGroupCard v-for="(g, i) in filteredGroups" :key="g.key" :group="g" :cog="cog"
:collapsed="collapsedKeys.has(g.key)" :style="{ animationDelay: (i * 45) + 'ms' }"
@toggle="toggle(g.key)" @rowmenu="$emit('rowmenu', $event)" @row-appear="$emit('row-appear', $event)" />
@toggle="toggle(g.key)" @rowmenu="$emit('rowmenu', $event)" @row-appear="$emit('row-appear', $event)" @refresh-node="$emit('refresh-node', $event)" />
</div>

<!-- List: one row per subscription, same delegation in the cells -->
Expand Down Expand Up @@ -83,7 +83,7 @@ const props = defineProps({
cog: { type: Boolean, default: true }, // show the host overflow (unsubscribe) cog
storageKey: { type: String, default: '' }, // localStorage scope for the cards/list choice; '' disables persistence
})
defineEmits(['rowmenu', 'row-appear'])
defineEmits(['rowmenu', 'row-appear', 'refresh-node'])

const t = useI18nStore().t

Expand Down
4 changes: 4 additions & 0 deletions ui/src/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ export default {
'home.tilesSmall': 'Small tiles',
'home.tilesMedium': 'Medium tiles',
'home.tilesList': 'List',
'home.nodeStatus.up': 'UP',
'home.nodeStatus.noStatus': 'No status',
'home.nodeStatus.down': 'DOWN',
'home.nodeStatus.refresh': 'Refresh node status',

// Projects
'project.title': 'Projects',
Expand Down
4 changes: 4 additions & 0 deletions ui/src/i18n/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ export default {
'home.tilesSmall': 'Tuiles petites',
'home.tilesMedium': 'Tuiles moyennes',
'home.tilesList': 'Liste',
'home.nodeStatus.up': 'UP',
'home.nodeStatus.noStatus': 'Sans statut',
'home.nodeStatus.down': 'DOWN',
'home.nodeStatus.refresh': 'Rafraîchir le statut du nœud',

// Projets
'project.title': 'Projets',
Expand Down
72 changes: 69 additions & 3 deletions ui/src/views/HomeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
</LjPageHeader>

<SubscriptionsPanel :groups="groups" :loading="loading && !groups.length" default-view="cards" storage-key="home"
searchable collapsible :cog="false" @row-appear="onRowAppear">
searchable collapsible :cog="false" @row-appear="onRowAppear" @refresh-node="onRefreshNode">
<template #toolbar>
<label class="demo-toggle" :class="{ on: demo }">
<input type="checkbox" v-model="demo" />
Expand Down Expand Up @@ -89,6 +89,11 @@ const subscriptions = ref([])
const usersTotal = ref(0)
// Full per-subscription details (parameters/data/status), fetched lazily.
const detailsById = ref(new Map())
// Aggregated subscription status per node (id → { total, up, down }), from
// GET rest/node/status/subscription. Drives the 3-state bar on each card.
const nodeStats = ref(new Map())
// Group keys whose node status is being re-checked (spinner on the button).
const refreshingKeys = ref(new Set())

const nodesMap = computed(() => {
const m = {}
Expand Down Expand Up @@ -124,6 +129,23 @@ async function load() {
loading.value = false
}

// Fetch the aggregated per-node subscription status. The backend keys the
// stats by the subscription's node (instance level, e.g. `service:prov:aws`),
// with `values = { total, UP, DOWN }`; everything not UP/DOWN is "no status".
async function loadNodeStats() {
try {
const data = await api.get('rest/node/status/subscription')
const m = new Map()
if (Array.isArray(data)) {
for (const e of data) {
const v = e?.values || {}
m.set(e.node, { total: v.total || 0, up: v.UP || 0, down: v.DOWN || 0 })
}
}
nodeStats.value = m
} catch { /* keep the previous stats on a transient failure */ }
}

// Group the current user's subscriptions by tool, merging any lazily-fetched
// details. Each row keeps a `sub` object for the plugin delegation.
const realGroups = computed(() => {
Expand Down Expand Up @@ -159,9 +181,29 @@ const realGroups = computed(() => {
// One row per subscription, labelled by the project that owns it.
byTool.get(key).rows.push({ name: project?.name || project?.pkey || node.name || ('#' + s.id), status, pills, sub })
}
const stats = nodeStats.value
for (const g of out) {
const ok = g.rows.filter((r) => r.status === 'ok').length
g.health = g.rows.length ? ok / g.rows.length : 0

// A group is keyed at the TOOL level but aggregates one row per
// subscription, each carrying its own (instance-level) node id. The
// backend keys its stats by that same node id, so the group's status is
// the SUM over its distinct node ids.
const ids = [...new Set(g.rows.map((r) => r.sub?.node?.id).filter(Boolean))]
g.nodeIds = ids
if (stats.size && ids.some((id) => stats.has(id))) {
let total = 0
let up = 0
let down = 0
for (const id of ids) {
const s = stats.get(id)
if (s) { total += s.total; up += s.up; down += s.down }
}
g.nodeStatus = { total, up, down, noStatus: Math.max(0, total - up - down) }
} else {
g.nodeStatus = null
}
}
return out.sort((a, b) => b.rows.length - a.rows.length)
})
Expand All @@ -184,7 +226,12 @@ const demoGroups = computed(() => DEMO_TOOLS.map((td) => ({
rows: td.rows.map((r) => ({ name: r.n, status: r.s, pills: r.p, cost: r.cost, sub: null })),
})))

const groups = computed(() => (demo.value ? realGroups.value.concat(demoGroups.value) : realGroups.value))
// Inject the live per-group `refreshing` flag here (cheap map) so toggling it
// doesn't rebuild the heavier `realGroups` aggregation.
const groups = computed(() => {
const base = demo.value ? realGroups.value.concat(demoGroups.value) : realGroups.value
return base.map((g) => ({ ...g, refreshing: refreshingKeys.value.has(g.key) }))
})

const kpis = computed(() => [
{ l: 'Projets', v: projects.value.length.toLocaleString('fr-FR'), c: '#2f6df6', icon: 'mdi-folder-multiple-outline' },
Expand Down Expand Up @@ -225,7 +272,26 @@ async function flushDetails() {
detailsById.value = next // triggers a single regroup with the merged details
}

onMounted(load)
// Re-check the status of a group's nodes, then refresh the aggregated stats.
async function onRefreshNode(payload) {
const key = payload?.key
const ids = payload?.nodeIds || []
if (!key || !ids.length) return
refreshingKeys.value = new Set(refreshingKeys.value).add(key)
try {
await Promise.allSettled(ids.map((id) => api.post(`rest/node/status/refresh/${encodeURIComponent(id)}`)))
await loadNodeStats()
} finally {
const next = new Set(refreshingKeys.value)
next.delete(key)
refreshingKeys.value = next
}
}

onMounted(() => {
load()
loadNodeStats()
})
</script>

<style scoped>
Expand Down