feat(v2): Clients tab — LP management + settle balance modal (P9c)

Adds operator-scoped LP (liquidity provider) management. Operators
register LPs against specific machines, monitor remaining balances, and
settle small remainders via the P3e endpoint.

Template (Clients tab content + dialogs):
  - Table with columns: machine, LP (username/short user_id), wallet,
    DCA mode badge, remaining balance (color-coded: green if positive,
    grey if zero), autoforward icon (with tooltip showing LN address),
    status badge, action menu
  - Empty-state banners: orange if no machines yet (LPs are
    machine-scoped), blue if machines exist but no LPs registered
  - Register-LP dialog: machine select + user_id + wallet_id + display
    name + DCA mode (flow / fixed) + fixed-mode daily limit (conditional)
    + autoforward toggle + autoforward LN address (conditional)
  - Edit-LP dialog: same minus immutable user_id/wallet_id, plus status
    select (active/paused/closed)
  - Settle-balance dialog (closes #4): funding wallet select + exchange
    rate (operator-supplied) + optional amount_fiat (blank = full
    remaining) + notes textarea. Shows the LP's current remaining
    balance prominently before submission.

JS:
  - loadClients pulls all operator's LPs across their fleet
  - Per-LP balance summaries pre-loaded (one GET per LP — N+1, captured
    in review issue #11 M3 for follow-up with a single grouped JOIN)
  - openAddClientDialog / openEditClientDialog with separate cleaner
    helpers (_cleanClientCreate vs _cleanClientUpdate) since the v2
    API immutable-field rules differ between create and update
  - openSettleBalanceDialog refreshes balance immediately before
    showing the modal so the operator sees the up-to-date number
  - confirmDeleteClient + DELETE wired
  - machineNameById helper for displaying which machine an LP is at
  - machineOptions computed for the register-LP machine select
  - machinesById computed cache (avoids O(N*M) lookups in render loop)

Routes wired:
  GET    /api/v1/dca/clients
  GET    /api/v1/dca/clients/{id}/balance
  POST   /api/v1/dca/clients
  PUT    /api/v1/dca/clients/{id}
  DELETE /api/v1/dca/clients/{id}
  POST   /api/v1/dca/clients/{id}/settle

Refs: aiolabs/satmachineadmin#9 — closes the Clients-tab gap in #4 +
exposes the autoforward setting (#8) in operator UI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 18:03:29 +02:00
commit 0800a1acb0
2 changed files with 498 additions and 2 deletions

View file

@ -17,6 +17,7 @@ const SUPER_FEE_PATH = `${API}/super-config`
const MACHINES_PATH = `${API}/machines`
const SETTLEMENTS_PATH = `${API}/settlements`
const STUCK_PATH = `${API}/settlements/stuck`
const CLIENTS_PATH = `${API}/clients`
const SETTLEMENT_STATUS_COLOR = {
pending: 'grey',
@ -40,6 +41,8 @@ window.app = Vue.createApp({
// Server state ---------------------------------------------------
superConfig: null,
machines: [],
clients: [],
clientBalances: {}, // {client_id: ClientBalanceSummary}
worklistCount: 0,
// UI configuration -----------------------------------------------
@ -61,6 +64,25 @@ window.app = Vue.createApp({
pagination: {rowsPerPage: 10, sortBy: 'name'}
},
clientsTable: {
columns: [
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
{name: 'username', label: 'LP', field: 'username', align: 'left'},
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'},
{name: 'dca_mode', label: 'Mode', field: 'dca_mode', align: 'left'},
{
name: 'remaining_balance',
label: 'Balance',
field: 'remaining_balance',
align: 'right'
},
{name: 'autoforward', label: '→', field: 'autoforward_enabled', align: 'center'},
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'actions', label: '', field: 'id', align: 'right'}
],
pagination: {rowsPerPage: 25}
},
settlementsTable: {
columns: [
{name: 'status', label: 'Status', field: 'status', align: 'left'},
@ -111,6 +133,24 @@ window.app = Vue.createApp({
saving: false,
settlement: null,
note: ''
},
clientDialog: {
show: false,
saving: false,
mode: 'add', // 'add' | 'edit'
data: this._emptyClientForm()
},
settleBalanceDialog: {
show: false,
saving: false,
client: null,
balance: null,
data: {
funding_wallet_id: null,
exchange_rate: null,
amount_fiat: null,
notes: ''
}
}
}
},
@ -120,6 +160,17 @@ window.app = Vue.createApp({
// g.user is sometimes null on initial mount in LNbits 1.4 — guard it.
const wallets = this.g?.user?.wallets || []
return wallets.map(w => ({label: w.name, value: w.id}))
},
machineOptions() {
return this.machines.map(m => ({
label: m.name || this.shortNpub(m.machine_npub),
value: m.id
}))
},
machinesById() {
const map = {}
for (const m of this.machines) map[m.id] = m
return map
}
},
@ -137,6 +188,7 @@ window.app = Vue.createApp({
await Promise.all([
this.loadSuperConfig(),
this.loadMachines(),
this.loadClients(),
this.loadWorklistCount()
])
} finally {
@ -144,6 +196,38 @@ window.app = Vue.createApp({
}
},
async loadClients() {
try {
const {data} = await LNbits.api.request('GET', CLIENTS_PATH)
this.clients = data || []
// N+1 acceptable for fleet sizes ~50; review #11 captures the
// single-grouped-JOIN follow-up (M3).
await Promise.all(
this.clients.map(c => this._loadClientBalance(c.id))
)
} catch (e) {
this.clients = []
this._notifyError(e, 'Failed to load LPs')
}
},
async _loadClientBalance(clientId) {
try {
const {data} = await LNbits.api.request(
'GET', `${CLIENTS_PATH}/${clientId}/balance`
)
this.clientBalances[clientId] = data
} catch (e) {
delete this.clientBalances[clientId]
}
},
machineNameById(machineId) {
const m = this.machinesById[machineId]
if (!m) return this.shortId(machineId)
return m.name || this.shortNpub(m.machine_npub)
},
async loadSuperConfig() {
try {
const {data} = await LNbits.api.request('GET', SUPER_FEE_PATH)
@ -424,6 +508,125 @@ window.app = Vue.createApp({
}
},
// -----------------------------------------------------------------
// Client (LP) management (P9c)
// -----------------------------------------------------------------
openAddClientDialog() {
this.clientDialog.mode = 'add'
this.clientDialog.data = this._emptyClientForm()
this.clientDialog.show = true
},
openEditClientDialog(client) {
this.clientDialog.mode = 'edit'
this.clientDialog.data = {
id: client.id,
machine_id: client.machine_id,
user_id: client.user_id,
wallet_id: client.wallet_id,
username: client.username || '',
dca_mode: client.dca_mode,
fixed_mode_daily_limit: client.fixed_mode_daily_limit,
autoforward_enabled: !!client.autoforward_enabled,
autoforward_ln_address: client.autoforward_ln_address || '',
status: client.status
}
this.clientDialog.show = true
},
async submitClient() {
const d = this.clientDialog.data
this.clientDialog.saving = true
try {
if (this.clientDialog.mode === 'add') {
const body = this._cleanClientCreate(d)
const {data} = await LNbits.api.request('POST', CLIENTS_PATH, null, body)
this.clients.unshift(data)
await this._loadClientBalance(data.id)
Quasar.Notify.create({type: 'positive', message: 'LP registered'})
} else {
const body = this._cleanClientUpdate(d)
const {data} = await LNbits.api.request(
'PUT', `${CLIENTS_PATH}/${d.id}`, null, body
)
const idx = this.clients.findIndex(c => c.id === data.id)
if (idx >= 0) this.clients[idx] = data
Quasar.Notify.create({type: 'positive', message: 'LP updated'})
}
this.clientDialog.show = false
} catch (e) {
this._notifyError(e, 'Save failed')
} finally {
this.clientDialog.saving = false
}
},
confirmDeleteClient(client) {
Quasar.Dialog.create({
title: 'Delete LP?',
message:
`Remove <b>${client.username || this.shortId(client.user_id)}</b> from this machine. ` +
'Their existing deposits and payment history are preserved — only the registration row goes. Continue?',
html: true,
cancel: true,
persistent: true
}).onOk(async () => {
try {
await LNbits.api.request('DELETE', `${CLIENTS_PATH}/${client.id}`)
this.clients = this.clients.filter(c => c.id !== client.id)
delete this.clientBalances[client.id]
Quasar.Notify.create({type: 'positive', message: 'LP deleted'})
} catch (e) {
this._notifyError(e, 'Delete failed')
}
})
},
// -----------------------------------------------------------------
// Settle balance (P3e — closes #4)
// -----------------------------------------------------------------
async openSettleBalanceDialog(client) {
this.settleBalanceDialog.client = client
this.settleBalanceDialog.balance = this.clientBalances[client.id] || null
this.settleBalanceDialog.data = {
funding_wallet_id: null,
exchange_rate: null,
amount_fiat: null,
notes: ''
}
// Refresh balance to make sure we're showing the latest before settling.
await this._loadClientBalance(client.id)
this.settleBalanceDialog.balance = this.clientBalances[client.id] || null
this.settleBalanceDialog.show = true
},
async submitSettleBalance() {
const d = this.settleBalanceDialog
const body = {
funding_wallet_id: d.data.funding_wallet_id,
exchange_rate: Number(d.data.exchange_rate),
amount_fiat: d.data.amount_fiat ? Number(d.data.amount_fiat) : null,
notes: d.data.notes || null
}
d.saving = true
try {
await LNbits.api.request(
'POST',
`${CLIENTS_PATH}/${d.client.id}/settle`,
null,
body
)
// Refresh this client's balance so the table reflects the new remaining.
await this._loadClientBalance(d.client.id)
d.show = false
Quasar.Notify.create({type: 'positive', message: 'Balance settled'})
} catch (e) {
this._notifyError(e, 'Settle failed')
} finally {
d.saving = false
}
},
_replaceSettlement(updated) {
if (!updated) return
const idx = this.machineDetail.settlements.findIndex(
@ -470,6 +673,52 @@ window.app = Vue.createApp({
})
},
_emptyClientForm() {
return {
machine_id: null,
user_id: '',
wallet_id: '',
username: '',
dca_mode: 'flow',
fixed_mode_daily_limit: null,
autoforward_enabled: false,
autoforward_ln_address: '',
status: 'active'
}
},
_cleanClientCreate(d) {
return {
machine_id: d.machine_id,
user_id: (d.user_id || '').trim(),
wallet_id: (d.wallet_id || '').trim(),
username: (d.username || '').trim() || null,
dca_mode: d.dca_mode || 'flow',
fixed_mode_daily_limit:
d.dca_mode === 'fixed' && d.fixed_mode_daily_limit
? Number(d.fixed_mode_daily_limit) : null,
autoforward_enabled: !!d.autoforward_enabled,
autoforward_ln_address:
d.autoforward_enabled && d.autoforward_ln_address
? d.autoforward_ln_address.trim() : null
}
},
_cleanClientUpdate(d) {
return {
username: (d.username || '').trim() || null,
dca_mode: d.dca_mode,
fixed_mode_daily_limit:
d.dca_mode === 'fixed' && d.fixed_mode_daily_limit
? Number(d.fixed_mode_daily_limit) : null,
autoforward_enabled: !!d.autoforward_enabled,
autoforward_ln_address:
d.autoforward_enabled && d.autoforward_ln_address
? d.autoforward_ln_address.trim() : null,
status: d.status
}
},
_emptyMachineForm() {
return {
machine_npub: '',