feat(v2): machine detail dialog — settlements + per-row actions (P9b)

Adds the operator's primary workspace: a full-screen dialog opened from
any Fleet row that shows the machine's settlement history with action
menus for retry / partial-dispense / force-reset / note-add.

Template (templates/satmachineadmin/index.html):
  - Full-screen Quasar dialog with q-bar header (machine name + fiat
    chip + reload + close)
  - Machine metadata strip: npub (copyable), wallet_id, location,
    fallback_commission_pct
  - Settlements table: status badge, time, gross / net / commission
    (with super/op breakdown beneath), fiat amount, payment_hash short
  - Notes blob expansion under each settlement row (pre-formatted)
  - Per-row action menu (q-btn-dropdown):
      • Add note         — always available
      • Retry            — when status='errored'
      • Partial dispense — when status in {pending, errored}
      • Force-reset      — when status in {pending, processing}
  - Warning icon (⚠) on rows where used_fallback_split=true, namechecking
    aiolabs/lamassu-next#44 in the tooltip
  - Three sub-dialogs:
      • Partial-dispense with fraction/sats toggle + notes input
      • Add-note dialog (free-form, non-empty validation)
      • (Retry/force-reset use Quasar.Dialog inline)

JS (static/js/index.js):
  - viewMachine() opens detail and triggers reloadMachineDetail()
  - GET /api/v1/dca/machines/{id}/settlements feeds the table
  - confirmRetrySettlement → POST .../retry
  - openPartialDispense → POST .../partial-dispense
  - confirmForceReset    → POST .../force-reset
  - openSettlementNote   → POST .../notes
  - _replaceSettlement() updates the table row in-place from PUT/POST
    responses so the operator sees instant feedback without a reload
  - settlementStatusColor() maps statuses to Quasar badge colors
  - formatSats / formatFiat / formatTime helpers; respect locale

Also: added data/ + *.sqlite3 to .gitignore so the
2026-05-14 auth-key leak can't recur from this repo (the equivalent
fix already landed in satmachineclient on the matching branch).

Refs: aiolabs/satmachineadmin#9 — closes the operator-detail-view
gap for #3 (partial dispense) + #4 (settlement) UX

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 18:01:08 +02:00
commit 13ac33047b
3 changed files with 480 additions and 9 deletions

View file

@ -12,9 +12,20 @@
// - For pale backgrounds (bg-*-1), pair with explicit dark text class
// so dark-mode users don't get unreadable white-on-cream.
const SUPER_FEE_PATH = '/satmachineadmin/api/v1/dca/super-config'
const MACHINES_PATH = '/satmachineadmin/api/v1/dca/machines'
const STUCK_PATH = '/satmachineadmin/api/v1/dca/settlements/stuck'
const API = '/satmachineadmin/api/v1/dca'
const SUPER_FEE_PATH = `${API}/super-config`
const MACHINES_PATH = `${API}/machines`
const SETTLEMENTS_PATH = `${API}/settlements`
const STUCK_PATH = `${API}/settlements/stuck`
const SETTLEMENT_STATUS_COLOR = {
pending: 'grey',
processing: 'blue',
processed: 'green',
partial: 'orange',
refunded: 'purple',
errored: 'red'
}
window.app = Vue.createApp({
el: '#vue',
@ -50,6 +61,25 @@ window.app = Vue.createApp({
pagination: {rowsPerPage: 10, sortBy: 'name'}
},
settlementsTable: {
columns: [
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'created_at', label: 'Time', field: 'created_at', align: 'left'},
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
{name: 'net_sats', label: 'Net (→ LPs)', field: 'net_sats', align: 'right'},
{
name: 'commission_sats',
label: 'Commission',
field: 'commission_sats',
align: 'right'
},
{name: 'fiat_amount', label: 'Fiat', field: 'fiat_amount', align: 'right'},
{name: 'payment_hash', label: 'Hash', field: 'payment_hash', align: 'left'},
{name: 'actions', label: '', field: 'id', align: 'right'}
],
pagination: {rowsPerPage: 25, sortBy: 'created_at', descending: true}
},
// Dialog state ---------------------------------------------------
addMachineDialog: {
show: false,
@ -60,6 +90,27 @@ window.app = Vue.createApp({
show: false,
saving: false,
data: {}
},
machineDetail: {
show: false,
loading: false,
machine: null,
settlements: []
},
partialDispenseDialog: {
show: false,
saving: false,
settlement: null,
mode: 'fraction',
dispensed_fraction: null,
dispensed_sats: null,
notes: ''
},
noteDialog: {
show: false,
saving: false,
settlement: null,
note: ''
}
}
},
@ -225,16 +276,162 @@ window.app = Vue.createApp({
},
// -----------------------------------------------------------------
// Future: drill into a machine's detail view. Stub for P9a; P9b
// wires the actual settlement / telemetry / commission-splits panel.
// Machine detail dialog (P9b)
// -----------------------------------------------------------------
viewMachine(machine) {
Quasar.Notify.create({
type: 'info',
message: `Machine detail view lands in P9b. (selected ${machine.id})`
async viewMachine(machine) {
this.machineDetail.machine = machine
this.machineDetail.settlements = []
this.machineDetail.show = true
await this.reloadMachineDetail()
},
async reloadMachineDetail() {
if (!this.machineDetail.machine) return
this.machineDetail.loading = true
try {
const {data} = await LNbits.api.request(
'GET',
`${MACHINES_PATH}/${this.machineDetail.machine.id}/settlements`
)
this.machineDetail.settlements = data || []
} catch (e) {
this._notifyError(e, 'Failed to load settlements')
} finally {
this.machineDetail.loading = false
}
},
settlementStatusColor(status) {
return SETTLEMENT_STATUS_COLOR[status] || 'grey'
},
// -----------------------------------------------------------------
// Settlement actions: retry, partial-dispense, force-reset, note
// -----------------------------------------------------------------
confirmRetrySettlement(settlement) {
Quasar.Dialog.create({
title: 'Retry distribution?',
message:
'Voids any failed legs and re-runs the distribution chain. ' +
'Completed legs are never re-paid.',
cancel: true,
persistent: true
}).onOk(async () => {
try {
const {data} = await LNbits.api.request(
'POST',
`${SETTLEMENTS_PATH}/${settlement.id}/retry`
)
this._replaceSettlement(data)
Quasar.Notify.create({
type: 'positive',
message: `Settlement ${this.shortId(settlement.id)} re-run`
})
} catch (e) {
this._notifyError(e, 'Retry failed')
}
})
},
confirmForceReset(settlement) {
Quasar.Dialog.create({
title: 'Force-reset stuck settlement?',
message:
`Flips status '${settlement.status}' → 'errored' so you can then ` +
'retry. Only use if the processor truly crashed mid-flight — fresh ' +
'settlements are refused (default 30-minute age guard).',
cancel: true,
persistent: true
}).onOk(async () => {
try {
const {data} = await LNbits.api.request(
'POST',
`${SETTLEMENTS_PATH}/${settlement.id}/force-reset`
)
this._replaceSettlement(data)
Quasar.Notify.create({
type: 'warning',
message: `Settlement marked errored — hit Retry next`
})
} catch (e) {
this._notifyError(e, 'Force-reset failed')
}
})
},
openPartialDispense(settlement) {
this.partialDispenseDialog.settlement = settlement
this.partialDispenseDialog.mode = 'fraction'
this.partialDispenseDialog.dispensed_fraction = null
this.partialDispenseDialog.dispensed_sats = null
this.partialDispenseDialog.notes = ''
this.partialDispenseDialog.show = true
},
async submitPartialDispense() {
const d = this.partialDispenseDialog
const body = {notes: d.notes || null}
if (d.mode === 'fraction') {
body.dispensed_fraction = Number(d.dispensed_fraction)
} else {
body.dispensed_sats = Number(d.dispensed_sats)
}
d.saving = true
try {
const {data} = await LNbits.api.request(
'POST',
`${SETTLEMENTS_PATH}/${d.settlement.id}/partial-dispense`,
null,
body
)
this._replaceSettlement(data)
d.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Partial dispense applied; distribution re-running'
})
} catch (e) {
this._notifyError(e, 'Partial dispense failed')
} finally {
d.saving = false
}
},
openSettlementNote(settlement) {
this.noteDialog.settlement = settlement
this.noteDialog.note = ''
this.noteDialog.show = true
},
async submitNote() {
const d = this.noteDialog
if (!d.note || !d.note.trim()) return
d.saving = true
try {
const {data} = await LNbits.api.request(
'POST',
`${SETTLEMENTS_PATH}/${d.settlement.id}/notes`,
null,
{note: d.note.trim()}
)
this._replaceSettlement(data)
d.show = false
Quasar.Notify.create({type: 'positive', message: 'Note added'})
} catch (e) {
this._notifyError(e, 'Failed to add note')
} finally {
d.saving = false
}
},
_replaceSettlement(updated) {
if (!updated) return
const idx = this.machineDetail.settlements.findIndex(
s => s.id === updated.id
)
if (idx >= 0) this.machineDetail.settlements[idx] = updated
},
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------
@ -249,6 +446,23 @@ window.app = Vue.createApp({
return id.length <= 12 ? id : id.slice(0, 8) + '…'
},
formatSats(n) {
if (n == null) return '—'
return Number(n).toLocaleString()
},
formatFiat(amount, code) {
if (amount == null) return '—'
return `${Number(amount).toFixed(2)} ${code || ''}`.trim()
},
formatTime(ts) {
if (!ts) return ''
const d = new Date(ts)
if (isNaN(d.getTime())) return String(ts)
return d.toLocaleString()
},
copy(text) {
if (!text) return
Quasar.copyToClipboard(text).then(() => {