fix(settings): card the Lightning section, and stop it crying "not installed" mid-rotation
Two operator reports on the same screen. The section had no card. Every other Settings section wraps itself in `glass-card px-6 py-6 mb-6` — AccountSection, AIDataAccessSection, NodeCertificateSection, BackupSection, the lot — and this one rendered as bare text on the page. Reported twice, because the wrapper lives in the new component and nothing about adding `<LightningCredentialsSection />` to SystemSection.vue's list tells you it is missing. Heading moved to h2/text-xl to match its siblings. A test now asserts the card, so a third report is not needed. And rotating told the operator Lightning did not exist. Rotation restarts LND, so `status.installed` reads false for a moment — and the template read that literally: "Lightning is not set up on this node yet, so there are no credentials to rotate. Install the Lightning app first." Seconds after rotating. On a node with a working wallet. It also replaced the progress they had every reason to be watching, on the one action that invalidates every credential their wallet holds. A container briefly absent is what rotating LOOKS like, not evidence Lightning was never there. The not-installed message is now gated on `!rotationInFlight`, which covers both `running: true` and the awaitUntil window between asking for a rotation and the node reporting one — `installed` can already be false in that gap, so gating on `running` alone would have left the same hole. Mid-rotation with no status yet says "Rotating credentials — Lightning is restarting" instead of falling through to a details block with empty fields. awaitUntil became a ref so the computed re-evaluates rather than holding a stale value until some other reactive dependency happens to change. Three tests: the card exists; a running rotation does not claim Lightning is missing; and — the half that matters just as much — a node with genuinely no Lightning still gets told there is nothing to rotate, so the fix has not simply hidden a true statement. 16/16, vue-tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b6010af10d
commit
51a4b59ce8
@@ -36,12 +36,28 @@ let poll: ReturnType<typeof setInterval> | null = null
|
||||
///
|
||||
/// Bounded rather than a plain flag, so a request the node accepted but never
|
||||
/// acted on stops polling instead of hammering it forever.
|
||||
let awaitUntil = 0
|
||||
const awaitUntil = ref(0)
|
||||
const AWAIT_START_MS = 120_000
|
||||
|
||||
const rotation = computed<LndRotationProgress | null>(() => status.value?.rotation ?? null)
|
||||
const isRunning = computed(() => rotation.value?.running === true)
|
||||
|
||||
/// Ticks while a rotation is being awaited, so `rotationInFlight` re-evaluates
|
||||
/// as the await window expires instead of holding a stale value until the next
|
||||
/// poll happens to touch a reactive dependency.
|
||||
const now = ref(Date.now())
|
||||
|
||||
/// Is a rotation happening, INCLUDING the gap between asking for one and the
|
||||
/// node reporting it?
|
||||
///
|
||||
/// Rotation restarts LND, so `status.installed` goes false for a moment
|
||||
/// mid-rotation. Read literally that says "Lightning is not set up on this
|
||||
/// node" — which the screen then told the operator, seconds after they
|
||||
/// rotated, on a node with a working Lightning wallet. The container being
|
||||
/// briefly absent is what rotating LOOKS like, not evidence it was never
|
||||
/// there.
|
||||
const rotationInFlight = computed(() => isRunning.value || now.value < awaitUntil.value)
|
||||
|
||||
/** A finished rotation, successful or not. `ok` is null while running. */
|
||||
const finished = computed(
|
||||
() => rotation.value !== null && !rotation.value.running && rotation.value.ok !== null,
|
||||
@@ -68,8 +84,9 @@ async function load() {
|
||||
/// waking the node every few seconds.
|
||||
function syncPolling() {
|
||||
const running = status.value?.rotation.running === true
|
||||
if (running) awaitUntil = 0
|
||||
if (running || Date.now() < awaitUntil) startPolling()
|
||||
if (running) awaitUntil.value = 0
|
||||
now.value = Date.now()
|
||||
if (running || Date.now() < awaitUntil.value) startPolling()
|
||||
else stopPolling()
|
||||
}
|
||||
|
||||
@@ -103,7 +120,8 @@ async function rotate() {
|
||||
try {
|
||||
await rpcClient.lndRotateMacaroons(password.value)
|
||||
closeConfirm()
|
||||
awaitUntil = Date.now() + AWAIT_START_MS
|
||||
awaitUntil.value = Date.now() + AWAIT_START_MS
|
||||
now.value = Date.now()
|
||||
startPolling()
|
||||
await load()
|
||||
} catch (e) {
|
||||
@@ -155,8 +173,16 @@ onUnmounted(stopPolling)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-base font-medium text-white/90 mb-1">Lightning credentials</h3>
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<!-- glass-card, like every other Settings section (AccountSection,
|
||||
AIDataAccessSection, NodeCertificateSection, BackupSection …). This
|
||||
rendered as bare text on the Settings page twice, because a new
|
||||
section carries its own wrapper and nothing about adding it to
|
||||
SystemSection.vue's list reminds you. Heading is h2/text-xl to match
|
||||
those siblings. Kept INSIDE the root: a leading comment makes the
|
||||
component a fragment, which drops the root class and breaks attribute
|
||||
inheritance. -->
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Lightning credentials</h2>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Wallet apps like Zeus connect to this node using a Lightning credential — a
|
||||
token that lets them spend. Rotating replaces every one of them, so anything
|
||||
@@ -173,14 +199,29 @@ onUnmounted(stopPolling)
|
||||
Could not read the Lightning credential state: {{ loadError }}
|
||||
</div>
|
||||
|
||||
<!-- `&& !rotationInFlight`: rotating restarts LND, so `installed` reads
|
||||
false for a moment mid-rotation. Without the guard this told the
|
||||
operator "Lightning is not set up on this node yet" seconds after they
|
||||
rotated on a node with a working wallet — and it replaced the progress
|
||||
they were watching. A container briefly absent is what rotating looks
|
||||
like, not proof Lightning was never installed. -->
|
||||
<div
|
||||
v-else-if="!status?.installed"
|
||||
v-else-if="!status?.installed && !rotationInFlight"
|
||||
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
|
||||
>
|
||||
Lightning is not set up on this node yet, so there are no credentials to
|
||||
rotate. Install the Lightning app first.
|
||||
</div>
|
||||
|
||||
<!-- Mid-rotation with no status to render yet: say what is happening
|
||||
rather than falling through to the details block with empty fields. -->
|
||||
<div
|
||||
v-else-if="!status?.installed"
|
||||
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
|
||||
>
|
||||
Rotating credentials — Lightning is restarting. This takes a moment.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- What exists right now -->
|
||||
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
|
||||
@@ -289,4 +289,65 @@ describe('LightningCredentialsSection', () => {
|
||||
await flushPromises()
|
||||
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterLoad)
|
||||
})
|
||||
|
||||
it('renders inside a card, like every other Settings section', () => {
|
||||
// Operator-reported twice: the section rendered as bare text on the
|
||||
// Settings page. A new section carries its own wrapper, and nothing about
|
||||
// adding it to SystemSection.vue's list reminds you it needs one.
|
||||
// `wrapper.element` is not the div: the confirm modal is a second root
|
||||
// node, so the component is a fragment. Assert on the first div.
|
||||
const wrapper = mountSection()
|
||||
expect(wrapper.find('div').classes()).toContain('glass-card')
|
||||
})
|
||||
|
||||
it('does not claim Lightning is missing while a rotation is running', async () => {
|
||||
// Rotation restarts LND, so `installed` goes false for a moment. The
|
||||
// screen used to read that literally and tell the operator "Lightning is
|
||||
// not set up on this node yet" — seconds after they rotated, on a node
|
||||
// with a working wallet — replacing the progress they were watching.
|
||||
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
|
||||
status({ installed: false, rotation: { ...idleRotation(), running: true } }),
|
||||
)
|
||||
|
||||
const wrapper = mountSection()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Lightning is not set up on this node yet')
|
||||
expect(wrapper.text()).toContain('Lightning is restarting')
|
||||
})
|
||||
|
||||
it('still tells a node with no Lightning that there is nothing to rotate', async () => {
|
||||
// The other half: the message must survive for its real audience, or the
|
||||
// fix above has just hidden a true statement.
|
||||
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status({ installed: false }))
|
||||
|
||||
const wrapper = mountSection()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Lightning is not set up on this node yet')
|
||||
})
|
||||
|
||||
it('does not claim Lightning is missing in the gap before the node reports the rotation', async () => {
|
||||
// The window `awaitUntil` exists for: the rotate RPC has been accepted but
|
||||
// the node has not yet reported `running: true`. `installed` can already be
|
||||
// false there, so the guard has to cover the await window too, not just
|
||||
// `running`.
|
||||
vi.mocked(rpcClient.lndMacaroonStatus)
|
||||
.mockResolvedValueOnce(status())
|
||||
.mockResolvedValue(status({ installed: false }))
|
||||
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue(undefined as never)
|
||||
|
||||
const wrapper = mountSection()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
const confirm = wrapper.findAll('button').find((b) => /rotate/i.test(b.text()))
|
||||
if (confirm) {
|
||||
await confirm.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
expect(wrapper.text()).not.toContain('Lightning is not set up on this node yet')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user