Skip to content

Commit 8b9fe24

Browse files
Add Rebirth button to Edge Manager node and device lists (#628)
## Summary - Per-row Rebirth button on Node and Device lists in the Edge Manager - Sends `Node Control/Rebirth` or `Device Control/Rebirth` via CmdEsc - Button only shown if user has the Rebirth CCL permission (checked via Auth HTTP API with wildcard support) - Loading spinner during request, success/error toasts - Tooltip on hover ## Changes - `RebirthButton.vue` - new component with CmdEsc integration and tooltip - `useCanRebirth.js` - composable that checks Rebirth ACL per target - `nodeColumns.ts` / `EdgeCluster.vue` - Rebirth column in node list - `deviceColumns.ts` / `Node.vue` - Rebirth column in device list - `useNodeStore.js` - added sparkplugAddress config ## Test Plan - [x] Verify Rebirth button appears for nodes/devices the user has permission on - [x] Verify button is hidden for users without Rebirth CCL permission - [x] Click Rebirth on a node - verify NBIRTH is re-published - [x] Click Rebirth on a device - verify DBIRTH is re-published - [x] Verify loading spinner and success toast - [x] Verify tooltip shows on hover
2 parents f46612c + f5924bc commit 8b9fe24

9 files changed

Lines changed: 602 additions & 5 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<template>
2+
<TooltipProvider v-if="canRebirth">
3+
<Tooltip>
4+
<TooltipTrigger as-child>
5+
<Button
6+
size="xs"
7+
variant="outline"
8+
:disabled="loading"
9+
class="ml-auto flex items-center justify-center gap-1.5 hover:bg-gray-800 hover:text-white"
10+
@click.stop="rebirth"
11+
>
12+
<i class="fa-solid" :class="loading ? 'fa-circle-notch animate-spin' : 'fa-tower-broadcast'"></i>
13+
</Button>
14+
</TooltipTrigger>
15+
<TooltipContent>
16+
<p>Rebirth</p>
17+
</TooltipContent>
18+
</Tooltip>
19+
</TooltipProvider>
20+
</template>
21+
22+
<script>
23+
import { useServiceClientStore } from '@store/serviceClientStore.js'
24+
import { Button } from '@components/ui/button/index.js'
25+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
26+
import { toast } from 'vue-sonner'
27+
28+
export default {
29+
components: { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger },
30+
props: {
31+
address: { type: String, required: true },
32+
name: { type: String, required: true },
33+
isDevice: { type: Boolean, default: false },
34+
canRebirth: { type: Boolean, default: false },
35+
},
36+
data () {
37+
return { loading: false }
38+
},
39+
methods: {
40+
async rebirth () {
41+
this.loading = true
42+
const ctrl = this.isDevice ? 'Device Control' : 'Node Control'
43+
try {
44+
await useServiceClientStore().client.CmdEsc.request_cmd({
45+
address: this.address,
46+
name: `${ctrl}/Rebirth`,
47+
type: 'Boolean',
48+
value: true,
49+
})
50+
toast.success(`Rebirth sent to ${this.name}`)
51+
} catch (e) {
52+
if (e.status === 403) {
53+
toast.error(`Permission denied: cannot rebirth ${this.name}`)
54+
} else {
55+
toast.error(`Failed to rebirth ${this.name}`)
56+
}
57+
console.error(e)
58+
} finally {
59+
this.loading = false
60+
}
61+
}
62+
}
63+
}
64+
</script>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (c) University of Sheffield AMRC 2025.
3+
*/
4+
5+
import { ref, watch } from 'vue'
6+
import { useServiceClientStore } from '@store/serviceClientStore.js'
7+
8+
const REBIRTH_PERMISSION = 'fbb9c25d-386d-4966-a325-f16471d9f7be'
9+
const WILDCARD = '00000000-0000-0000-0000-000000000000'
10+
11+
export function useCanRebirth (targets) {
12+
const permissions = ref(new Map())
13+
14+
watch(targets, async (uuids) => {
15+
if (!uuids || uuids.length === 0) return
16+
17+
const s = useServiceClientStore()
18+
const results = new Map()
19+
20+
// Fetch the full ACL via the Auth service HTTP API.
21+
// We call ServiceInterface.fetch directly to avoid the
22+
// RxClient notify-based override of fetch_raw_acl.
23+
let acl
24+
try {
25+
const principal = `${s.username}@${s.baseUrl.toUpperCase()}`
26+
const [st, json] = await s.client.Auth.fetch(
27+
`v2/acl/kerberos/${principal}`
28+
)
29+
if (st !== 200) throw new Error(`ACL fetch returned ${st}`)
30+
acl = json
31+
} catch (e) {
32+
console.error('Failed to fetch ACL for rebirth check:', e)
33+
return
34+
}
35+
36+
for (const uuid of uuids) {
37+
const allowed = acl.some(ace =>
38+
ace.permission === REBIRTH_PERMISSION
39+
&& (ace.target === uuid || ace.target === WILDCARD)
40+
)
41+
results.set(uuid, allowed)
42+
}
43+
44+
permissions.value = results
45+
}, { immediate: true })
46+
47+
return permissions
48+
}

acs-admin/src/pages/EdgeManager/EdgeClusters/EdgeCluster.vue

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,11 @@
159159
</template>
160160

161161
<script>
162+
import { computed } from 'vue'
162163
import { useServiceClientStore } from '@store/serviceClientStore.js'
163164
import { useEdgeClusterStore } from '@store/useEdgeClusterStore.js'
164165
import { useNodeStore } from '@store/useNodeStore.js'
166+
import { useCanRebirth } from '@/composables/useCanRebirth.js'
165167
import { UUIDs } from '@amrc-factoryplus/service-client'
166168
import EdgeContainer from '@components/Containers/EdgeContainer.vue'
167169
import EdgePageSkeleton from '@components/EdgeManager/EdgePageSkeleton.vue'
@@ -213,10 +215,15 @@ export default {
213215
},
214216
215217
setup () {
218+
const n = useNodeStore()
219+
const nodeUuids = computed(() => n.data?.map(e => e.uuid) ?? [])
220+
const canRebirthMap = useCanRebirth(nodeUuids)
221+
216222
return {
217223
e: useEdgeClusterStore(),
218224
dp: useDeploymentStore(),
219-
n: useNodeStore(),
225+
n,
226+
canRebirthMap,
220227
hostColumns,
221228
nodeColumns,
222229
deploymentColumns,
@@ -234,7 +241,11 @@ export default {
234241
},
235242
236243
nodes () {
237-
return Array.isArray(this.n.data) ? this.n.data.filter(e => e.deployment?.cluster === this.cluster.uuid) : []
244+
const filtered = Array.isArray(this.n.data) ? this.n.data.filter(e => e.deployment?.cluster === this.cluster.uuid) : []
245+
return filtered.map(n => ({
246+
...n,
247+
_canRebirth: this.canRebirthMap?.get(n.uuid) ?? false
248+
}))
238249
},
239250
240251
deployments () {

acs-admin/src/pages/EdgeManager/EdgeClusters/nodeColumns.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {ColumnDef} from '@tanstack/vue-table'
66
import {h} from 'vue'
77

88
import DataTableColumnHeader from '@/components/ui/data-table/DataTableColumnHeader.vue'
9+
import RebirthButton from '@/components/EdgeManager/RebirthButton.vue'
910

1011
export interface Host {
1112
uuid: string,
@@ -52,5 +53,22 @@ export const nodeColumns: ColumnDef<Host>[] = [
5253
filterFn: (row, id, value) => {
5354
return value.includes(row.getValue(id))
5455
},
56+
},
57+
{
58+
id: 'actions',
59+
header: () => null,
60+
cell: ({row}) => {
61+
const addr = row.original.sparkplugAddress
62+
if (!addr) return null
63+
const addressStr = `${addr.group_id}/${addr.node_id}`
64+
return h(RebirthButton, {
65+
address: addressStr,
66+
name: row.original.name,
67+
canRebirth: row.original._canRebirth ?? false,
68+
class: 'ml-auto'
69+
})
70+
},
71+
enableSorting: false,
72+
enableHiding: false,
5573
}
5674
]

acs-admin/src/pages/EdgeManager/Nodes/Node.vue

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,12 @@
129129
</template>
130130

131131
<script>
132+
import { computed } from 'vue'
132133
import { useNodeStore } from '@store/useNodeStore.js'
133134
import { useEdgeClusterStore } from '@store/useEdgeClusterStore.js'
134135
import { useDeviceStore } from '@store/useDeviceStore.js'
135136
import { useConnectionStore } from '@store/useConnectionStore.js'
137+
import { useCanRebirth } from '@/composables/useCanRebirth.js'
136138
import EdgeContainer from '@components/Containers/EdgeContainer.vue'
137139
import { Label } from '@components/ui/label/index.js'
138140
import { Input } from '@components/ui/input/index.js'
@@ -176,11 +178,16 @@ export default {
176178
},
177179
178180
setup () {
181+
const d = useDeviceStore()
182+
const deviceUuids = computed(() => d.data?.map(e => e.uuid) ?? [])
183+
const canRebirthMap = useCanRebirth(deviceUuids)
184+
179185
return {
180186
c: useEdgeClusterStore(),
181187
n: useNodeStore(),
182-
d: useDeviceStore(),
188+
d,
183189
cn: useConnectionStore(),
190+
canRebirthMap,
184191
inop,
185192
deviceColumns,
186193
connectionColumns,
@@ -206,7 +213,12 @@ export default {
206213
},
207214
208215
devices () {
209-
return Array.isArray(this.d.data) ? this.d.data.filter(e => e.deviceInformation?.node === this.node.uuid) : []
216+
const filtered = Array.isArray(this.d.data) ? this.d.data.filter(e => e.deviceInformation?.node === this.node.uuid) : []
217+
return filtered.map(d => ({
218+
...d,
219+
_nodeAddress: this.node.sparkplugAddress,
220+
_canRebirth: this.canRebirthMap?.get(d.uuid) ?? false
221+
}))
210222
},
211223
212224
connections () {

acs-admin/src/pages/EdgeManager/Nodes/deviceColumns.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {ColumnDef} from '@tanstack/vue-table'
66
import {h} from 'vue'
77

88
import DataTableColumnHeader from '@/components/ui/data-table/DataTableColumnHeader.vue'
9+
import RebirthButton from '@/components/EdgeManager/RebirthButton.vue'
910

1011
export interface Device {
1112
uuid: string,
@@ -31,6 +32,24 @@ export const deviceColumns: ColumnDef<Device>[] = [
3132
filterFn: (row, id, value) => {
3233
return value.includes(row.getValue(id))
3334
},
35+
},
36+
{
37+
id: 'actions',
38+
header: () => null,
39+
cell: ({row}) => {
40+
const addr = row.original._nodeAddress
41+
const sparkplugName = row.original.deviceInformation?.sparkplugName
42+
if (!addr || !sparkplugName) return null
43+
const addressStr = `${addr.group_id}/${addr.node_id}/${sparkplugName}`
44+
return h(RebirthButton, {
45+
address: addressStr,
46+
name: row.original.name,
47+
isDevice: true,
48+
canRebirth: row.original._canRebirth ?? false,
49+
})
50+
},
51+
enableSorting: false,
52+
enableHiding: false,
3453
}
3554
// {
3655
// accessorKey: 'schema',

acs-admin/src/store/useNodeStore.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ import { useStore } from '@store/useStore.ts'
66
import { UUIDs } from '@amrc-factoryplus/service-client'
77

88
export const useNodeStore = useStore('node', UUIDs.Class.EdgeAgent, {
9-
deployment: UUIDs.App.EdgeAgentDeployment
9+
deployment: UUIDs.App.EdgeAgentDeployment,
10+
sparkplugAddress: UUIDs.App.SparkplugAddress
1011
})
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Rebirth Button in Admin UI
2+
3+
Date: 2026-04-09
4+
Author: Alex Godbehere
5+
6+
## Problem
7+
8+
There is no way to trigger a Sparkplug Rebirth (NBIRTH/DBIRTH) from the
9+
Admin UI. When edge agents get stuck (e.g. after a ConfigDB migration
10+
changes device UUIDs), the only option is to manually restart pods or
11+
use kubectl. A Rebirth button in the UI would let operators force a
12+
re-publish of the birth certificate without restarting the agent.
13+
14+
## Solution
15+
16+
Add a per-row "Rebirth" button to the Node list and Device list views
17+
in the Edge Manager. The button is only shown if the current user has
18+
the Rebirth CCL permission on that specific node/device.
19+
20+
### Key UUIDs
21+
22+
- **Rebirth CCL permission**: `fbb9c25d-386d-4966-a325-f16471d9f7be`
23+
- **Command Definition app**: `60e99f28-67fe-4344-a6ab-b1edb8b8e810`
24+
- **CmdEsc permission set group**: `9584ee09-a35a-4278-bc13-21a8be1f007c`
25+
26+
### How Rebirth works
27+
28+
The `CmdEsc.rebirth(address)` method in `lib/js-service-client/lib/service/cmdesc.js`
29+
sends a Sparkplug NCMD/DCMD via the Command Escalation service:
30+
31+
- For nodes: `Node Control/Rebirth` (Boolean, true)
32+
- For devices: `Device Control/Rebirth` (Boolean, true)
33+
34+
The CmdEsc service validates the user's ACL before forwarding the
35+
command. The edge agent handles the NCMD at
36+
`acs-edge/lib/sparkplugNode.ts:339-341` and re-publishes its birth
37+
certificate.
38+
39+
### Changes
40+
41+
#### 1. Node store - add Sparkplug address
42+
43+
**File:** `acs-admin/src/store/useNodeStore.js`
44+
45+
Add `sparkplugAddress: UUIDs.App.SparkplugAddress` to the loaded
46+
configs. The device store already loads this; the node store does not.
47+
48+
#### 2. ACL check composable
49+
50+
**File:** `acs-admin/src/composables/useCanRebirth.js` (new)
51+
52+
A Vue composable that, given a list of node/device UUIDs, checks
53+
whether the current user has the Rebirth permission
54+
(`fbb9c25d-386d-4966-a325-f16471d9f7be`) on each target via
55+
`client.Auth.check_acl()`.
56+
57+
Returns a reactive `Map<uuid, boolean>` so the template can look up
58+
each row.
59+
60+
The current user's principal is resolved from `serviceClientStore.username`
61+
(a Kerberos principal name) which `Auth.fetch_acl()` accepts directly.
62+
63+
Calls are made per target UUID. Results are cached for the lifetime of
64+
the composable.
65+
66+
#### 3. Node list - Rebirth column
67+
68+
**File:** `acs-admin/src/pages/EdgeManager/EdgeClusters/EdgeCluster.vue`
69+
**File:** `acs-admin/src/pages/EdgeManager/EdgeClusters/nodeColumns.ts`
70+
71+
Add a new column to the node DataTable with a Rebirth button per row.
72+
The column uses a custom cell renderer (Vue `h()` function or inline
73+
component) that:
74+
75+
- Checks `canRebirth.get(row.original.uuid)` to show/hide the button
76+
- On click, calls `client.CmdEsc.rebirth(address)` where `address` is
77+
built from the node's `sparkplugAddress` config
78+
- Shows a loading spinner on the button while the request is in flight
79+
- Shows a success toast on completion, error toast on failure
80+
81+
#### 4. Device list - Rebirth column
82+
83+
**File:** `acs-admin/src/pages/EdgeManager/Nodes/Node.vue`
84+
**File:** `acs-admin/src/pages/EdgeManager/Nodes/deviceColumns.ts`
85+
86+
Same pattern as the node list. The device's Sparkplug address is already
87+
available in the device store.
88+
89+
### UX
90+
91+
- Button style: small ghost/secondary button with a refresh icon
92+
- Loading: spinner replaces the icon while the request is in flight
93+
- Success: toast "Rebirth sent to {name}"
94+
- Error (403): toast "Permission denied: cannot rebirth {name}"
95+
- Error (other): toast "Failed to rebirth {name}"
96+
- Button hidden entirely if user lacks permission on that target
97+
98+
### What this doesn't change
99+
100+
- No backend changes - CmdEsc and the edge agent already support Rebirth
101+
- No new permissions - the Rebirth CCL already exists
102+
- No changes to the Sparkplug protocol handling

0 commit comments

Comments
 (0)