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:
archipelago
2026-08-08 13:11:18 -04:00
co-authored by Claude Opus 5
parent b6010af10d
commit 51a4b59ce8
2 changed files with 109 additions and 7 deletions
@@ -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')
})
})