From 0800a1acb0c6a38fe4c11354a93701eb1c5e7a9c Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 14 May 2026 18:03:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(v2):=20Clients=20tab=20=E2=80=94=20LP=20ma?= =?UTF-8?q?nagement=20+=20settle=20balance=20modal=20(P9c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- static/js/index.js | 249 ++++++++++++++++++++++++++ templates/satmachineadmin/index.html | 251 ++++++++++++++++++++++++++- 2 files changed, 498 insertions(+), 2 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index b5ad2ff..084eec3 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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 ${client.username || this.shortId(client.user_id)} 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: '', diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index bd441b1..2688181 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -167,9 +167,112 @@ - - Clients tab — pending P9c. +
+
+
Liquidity providers
+

+ LPs receive proportional DCA distributions from your machines. + Balances reflect deposits less the sats they've been paid. +

+
+
+ +
+
+ + + + Register at least one machine before adding LPs — an LP is scoped + to a specific machine. + + + + No LPs yet. Use Register LP to add one at any of your machines. + + + + +
@@ -523,6 +626,150 @@ + + + + + + +
+ + +
+ +

+ LPs receive DCA distributions proportional to their remaining + balance. Each LP is scoped to a single machine; the same LP user + can register at multiple machines as separate rows. +

+ + + + + + + + + + + + + + + + + + +
+ + + + +
+
+ + + + + + + +
Settle LP balance
+ + +
+ + + + Pay the LP's remaining fiat balance in sats from your wallet at the + rate you choose. Useful to zero out small balances that would + otherwise shrink forever via proportional shares. + + +
+
Remaining balance
+
+
+ + + + + + + + +
+ + + + +
+
+