diff --git a/.gitignore b/.gitignore index 0152b6e..6228718 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ __pycache__ node_modules .mypy_cache .venv + +# LNbits runtime data — auth keys, dev DB files, etc. +data/ +*.sqlite3 +*.sqlite3-journal diff --git a/static/js/index.js b/static/js/index.js index f1b20a8..b5ad2ff 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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(() => { diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index dc1fd8d..bd441b1 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -271,6 +271,258 @@ + + + + + + +
+ + + + Reload settlements + + + Close + +
+ + +
+
+
npub
+ +
+
+
Wallet
+ +
+
+
Location
+ +
+
+
+ Fallback commission % +
+ +
+
+ + + +
+
+
Settlements
+

+ Every bitSpire transaction lands here. Click a row's menu for + retry / partial-dispense / notes. +

+
+
+ + + No settlements yet. They'll appear when bitSpire pays this machine's + wallet. + + + + + +
+
+
+ + + + + + + +
Apply partial dispense
+ + +
+ + + + Original gross: + . + Provide what was actually dispensed. Sat amounts will scale linearly, + the commission split will recompute, and distribution will re-run. + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + +
Add note to settlement
+ + +
+ +

+ Notes are append-only and timestamped. Use for reconciliation context, + off-LN refund records, dispute narrative, etc. +

+ +
+ + + + +
+
+