feat(v2): super-fee edit + Worklist + Reports (P9f+g, completes P9)
Combines the final three P9 pieces into a single commit since each is
small and they share the JS state plumbing.
Super-fee edit (P9f — visible only to super_user):
- "Edit" affordance on the platform-fee banner, gated on
g.user.super_user (LNbits passes this through windowMixin)
- Modal: super_fee_pct (decimal 0..1) + super_fee_wallet_id (text)
- PUT /api/v1/dca/super-config (check_super_user on the backend)
- Operators see the same banner read-only — no edit button rendered
Worklist tab (P9g part 1):
- Reuses GET /api/v1/dca/settlements/stuck?threshold_minutes=N
- Three labeled buckets: errored / stuck_pending / stuck_processing,
each with row count chip
- Per-row actions: open machine detail (reuses viewMachine), retry
(for errored), force-reset (for stuck — confirmation dialog warns
only-use-if-truly-stuck)
- Threshold input (default 30 min) + manual refresh button
- "All clear" green banner when worklist is empty
- Auto-loads on `created()` so the badge count is accurate from boot
Reports tab (P9g part 2):
- Four CSV download cards: machines / clients / deposits / payments
- Clients CSV merges in the per-LP balance summary from clientBalances
so the export captures total_deposits/payments/remaining + currency
- Payments CSV lazy-loads from GET /api/v1/dca/payments since payments
aren't cached in dashboard state (could be many rows)
- _downloadCsv helper properly quotes/escapes values with embedded
commas/quotes/newlines per RFC 4180
- All exports are client-side; no new endpoint required
P9 is now complete (P9a–P9g). The v1 super-only Lamassu dashboard is
fully replaced. Operators can register machines, manage LPs + deposits,
configure commission splits, work through errored settlements, and
export their data — all against the v2 backend.
Total v2 frontend: ~1326 lines JS + ~1349 lines template, replacing
the v1's 773 + 851. Increase is from the much larger v2 surface
(machines, leg-typed payments, commission editor, worklist, settle-
balance, partial-dispense, notes, force-reset, retry).
Refs: aiolabs/satmachineadmin#9 — completes P9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c8e629752
commit
f4eb7ec928
2 changed files with 420 additions and 7 deletions
|
|
@ -67,6 +67,40 @@ window.app = Vue.createApp({
|
|||
// across the current legs (purely visual; doesn't hit the server).
|
||||
commissionPreviewInput: 1000,
|
||||
|
||||
// Worklist (P9g)
|
||||
worklist: {
|
||||
errored: [],
|
||||
stuck_pending: [],
|
||||
stuck_processing: [],
|
||||
totalCount: 0
|
||||
},
|
||||
worklistLoading: false,
|
||||
worklistThreshold: 30,
|
||||
worklistTable: {
|
||||
columns: [
|
||||
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
|
||||
{name: 'created_at', label: 'Created', field: 'created_at', align: 'left'},
|
||||
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
|
||||
{
|
||||
name: 'error_message',
|
||||
label: 'Error',
|
||||
field: 'error_message',
|
||||
align: 'left'
|
||||
},
|
||||
{name: 'actions', label: '', field: 'id', align: 'right'}
|
||||
]
|
||||
},
|
||||
|
||||
// Reports
|
||||
reportsBusy: false,
|
||||
|
||||
// Super-fee edit dialog (super-only)
|
||||
superFeeDialog: {
|
||||
show: false,
|
||||
saving: false,
|
||||
data: {super_fee_pct: 0, super_fee_wallet_id: ''}
|
||||
},
|
||||
|
||||
// UI configuration -----------------------------------------------
|
||||
machinesTable: {
|
||||
columns: [
|
||||
|
|
@ -226,6 +260,32 @@ window.app = Vue.createApp({
|
|||
value: c.id
|
||||
}))
|
||||
},
|
||||
worklistBuckets() {
|
||||
return [
|
||||
{
|
||||
key: 'errored',
|
||||
label: 'Errored — needs retry',
|
||||
icon: 'error',
|
||||
color: 'red',
|
||||
rows: this.worklist.errored
|
||||
},
|
||||
{
|
||||
key: 'stuck_pending',
|
||||
label: 'Stuck pending — listener crashed before processing?',
|
||||
icon: 'hourglass_top',
|
||||
color: 'orange',
|
||||
rows: this.worklist.stuck_pending
|
||||
},
|
||||
{
|
||||
key: 'stuck_processing',
|
||||
label: 'Stuck processing — processor crashed mid-flight?',
|
||||
icon: 'sync_problem',
|
||||
color: 'purple',
|
||||
rows: this.worklist.stuck_processing
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
commissionScopeOptions() {
|
||||
const opts = [{label: 'Default ruleset (operator-wide)', value: null}]
|
||||
for (const m of this.machines) {
|
||||
|
|
@ -284,6 +344,7 @@ window.app = Vue.createApp({
|
|||
async created() {
|
||||
await this.refreshAll()
|
||||
await this.loadCommissionSplits()
|
||||
await this.loadWorklist()
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
@ -377,8 +438,8 @@ window.app = Vue.createApp({
|
|||
},
|
||||
|
||||
async loadWorklistCount() {
|
||||
// Light read used to badge the Worklist tab. The full worklist
|
||||
// panel lives in P9g; here we just count for the badge.
|
||||
// Light read for the tab badge — Worklist tab fetches the full
|
||||
// payload via loadWorklist when opened.
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', STUCK_PATH)
|
||||
this.worklistCount =
|
||||
|
|
@ -390,6 +451,162 @@ window.app = Vue.createApp({
|
|||
}
|
||||
},
|
||||
|
||||
async loadWorklist() {
|
||||
this.worklistLoading = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET', `${STUCK_PATH}?threshold_minutes=${this.worklistThreshold}`
|
||||
)
|
||||
this.worklist.errored = data?.errored || []
|
||||
this.worklist.stuck_pending = data?.stuck_pending || []
|
||||
this.worklist.stuck_processing = data?.stuck_processing || []
|
||||
this.worklist.totalCount =
|
||||
this.worklist.errored.length +
|
||||
this.worklist.stuck_pending.length +
|
||||
this.worklist.stuck_processing.length
|
||||
this.worklistCount = this.worklist.totalCount
|
||||
} catch (e) {
|
||||
this._notifyError(e, 'Failed to load worklist')
|
||||
} finally {
|
||||
this.worklistLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
async viewMachineFromWorklist(settlement) {
|
||||
const machine = this.machinesById[settlement.machine_id]
|
||||
if (!machine) return
|
||||
await this.viewMachine(machine)
|
||||
},
|
||||
|
||||
confirmRetryFromWorklist(settlement) {
|
||||
this.confirmRetrySettlement(settlement)
|
||||
// Drop from worklist on success (optimistic; reload covers re-eval).
|
||||
setTimeout(() => this.loadWorklist(), 500)
|
||||
},
|
||||
|
||||
confirmForceResetFromWorklist(settlement) {
|
||||
this.confirmForceReset(settlement)
|
||||
setTimeout(() => this.loadWorklist(), 500)
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Super-fee edit (P9f — super-only)
|
||||
// -----------------------------------------------------------------
|
||||
openSuperFeeDialog() {
|
||||
this.superFeeDialog.data = {
|
||||
super_fee_pct: this.superConfig?.super_fee_pct ?? 0,
|
||||
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || ''
|
||||
}
|
||||
this.superFeeDialog.show = true
|
||||
},
|
||||
|
||||
async submitSuperFee() {
|
||||
const d = this.superFeeDialog.data
|
||||
this.superFeeDialog.saving = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT', SUPER_FEE_PATH, null,
|
||||
{
|
||||
super_fee_pct: Number(d.super_fee_pct),
|
||||
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null
|
||||
}
|
||||
)
|
||||
this.superConfig = data
|
||||
this.superFeeDialog.show = false
|
||||
Quasar.Notify.create({type: 'positive', message: 'Platform fee updated'})
|
||||
} catch (e) {
|
||||
this._notifyError(e, 'Save failed')
|
||||
} finally {
|
||||
this.superFeeDialog.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Reports / CSV exports (P9g)
|
||||
// -----------------------------------------------------------------
|
||||
downloadMachinesCsv() {
|
||||
this._downloadCsv(
|
||||
'machines.csv',
|
||||
['id', 'machine_npub', 'wallet_id', 'name', 'location', 'fiat_code',
|
||||
'is_active', 'fallback_commission_pct', 'created_at'],
|
||||
this.machines
|
||||
)
|
||||
},
|
||||
|
||||
downloadClientsCsv() {
|
||||
const rows = this.clients.map(c => {
|
||||
const bal = this.clientBalances[c.id] || {}
|
||||
return {
|
||||
...c,
|
||||
machine_name: this.machineNameById(c.machine_id),
|
||||
remaining_balance: bal.remaining_balance ?? '',
|
||||
total_deposits: bal.total_deposits ?? '',
|
||||
total_payments: bal.total_payments ?? '',
|
||||
balance_currency: bal.currency ?? ''
|
||||
}
|
||||
})
|
||||
this._downloadCsv(
|
||||
'clients.csv',
|
||||
['id', 'machine_id', 'machine_name', 'user_id', 'wallet_id',
|
||||
'username', 'dca_mode', 'status', 'autoforward_enabled',
|
||||
'autoforward_ln_address', 'total_deposits', 'total_payments',
|
||||
'remaining_balance', 'balance_currency', 'created_at'],
|
||||
rows
|
||||
)
|
||||
},
|
||||
|
||||
downloadDepositsCsv() {
|
||||
this._downloadCsv(
|
||||
'deposits.csv',
|
||||
['id', 'client_id', 'machine_id', 'creator_user_id', 'amount',
|
||||
'currency', 'status', 'notes', 'created_at', 'confirmed_at'],
|
||||
this.deposits
|
||||
)
|
||||
},
|
||||
|
||||
async downloadPaymentsCsv() {
|
||||
// Payments are not pre-loaded; fetch on demand.
|
||||
this.reportsBusy = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', `${API}/payments`)
|
||||
this._downloadCsv(
|
||||
'payments.csv',
|
||||
['id', 'settlement_id', 'client_id', 'machine_id', 'leg_type',
|
||||
'destination_wallet_id', 'destination_ln_address', 'amount_sats',
|
||||
'amount_fiat', 'exchange_rate', 'status', 'external_payment_hash',
|
||||
'transaction_time', 'created_at', 'error_message'],
|
||||
data || []
|
||||
)
|
||||
} catch (e) {
|
||||
this._notifyError(e, 'Failed to fetch payments')
|
||||
} finally {
|
||||
this.reportsBusy = false
|
||||
}
|
||||
},
|
||||
|
||||
_downloadCsv(filename, columns, rows) {
|
||||
const escape = v => {
|
||||
if (v == null) return ''
|
||||
const s = String(v)
|
||||
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'
|
||||
return s
|
||||
}
|
||||
const header = columns.join(',')
|
||||
const body = rows.map(
|
||||
row => columns.map(col => escape(row[col])).join(',')
|
||||
).join('\n')
|
||||
const csv = header + '\n' + body
|
||||
const blob = new Blob([csv], {type: 'text/csv;charset=utf-8'})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Add machine
|
||||
// -----------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue