Skip to content

Commit 1da6535

Browse files
committed
Redesign dashboard: unified services table with referral links
Replace service cards + separate breakdown table with a single comprehensive table showing: Service (name links to referral), Status, Health, Balance, Change, CPU, Memory, Payout progress, and Actions (Claim, Restart, Stop, Logs).
1 parent afb51f5 commit 1da6535

3 files changed

Lines changed: 109 additions & 164 deletions

File tree

app/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,9 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]:
429429
cashout = svc.get("cashout", {})
430430
if cashout:
431431
entry["cashout"] = cashout
432+
referral = svc.get("referral", {})
433+
if referral:
434+
entry["referral_url"] = referral.get("signup_url", "")
432435
result.append(entry)
433436
return result
434437

app/static/js/app.js

Lines changed: 96 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -126,17 +126,15 @@ const CP = (() => {
126126
async function loadDashboard() {
127127
await Promise.all([
128128
loadDashboardStats(),
129-
loadDashboardServices(),
129+
loadServicesTable(),
130130
loadEarningsChart('7'),
131-
loadEarningsBreakdown(),
132131
]);
133132

134133
// Auto-refresh every 60 seconds
135134
if (refreshTimer) clearInterval(refreshTimer);
136135
refreshTimer = setInterval(() => {
137136
loadDashboardStats();
138-
loadDashboardServices();
139-
loadEarningsBreakdown();
137+
loadServicesTable();
140138
}, 60000);
141139
}
142140

@@ -168,93 +166,125 @@ const CP = (() => {
168166
}
169167
}
170168

171-
async function loadDashboardServices() {
172-
const container = document.getElementById('services-list');
169+
async function loadServicesTable() {
170+
const container = document.getElementById('services-table-container');
173171
if (!container) return;
174172

175173
try {
176-
const services = await api('/api/services/deployed');
174+
const [services, breakdown] = await Promise.all([
175+
api('/api/services/deployed'),
176+
api('/api/earnings/breakdown').catch(() => []),
177+
]);
178+
177179
if (!services || services.length === 0) {
178180
container.innerHTML = `
179-
<div class="empty-state">
180-
<div class="empty-state-icon">&#x1f680;</div>
181+
<div class="empty-state" style="padding:32px 0; text-align:center;">
181182
<div class="empty-state-title">No services deployed yet</div>
182183
<div class="empty-state-text">Get started by deploying your first passive income service.</div>
183-
<a href="/setup" class="btn btn-primary btn-lg">Setup Wizard</a>
184+
<a href="/setup" class="btn btn-primary" style="margin-top:12px;">Setup Wizard</a>
184185
</div>`;
185186
return;
186187
}
187-
container.innerHTML = services.map(renderServiceCard).join('');
188-
} catch (err) {
188+
189+
// Merge breakdown data into services by slug
190+
const breakdownMap = {};
191+
(breakdown || []).forEach(b => { breakdownMap[b.platform] = b; });
192+
193+
const rows = services.map(svc => renderServiceRow(svc, breakdownMap[svc.slug])).join('');
189194
container.innerHTML = `
190-
<div class="empty-state">
191-
<div class="empty-state-icon">&#x1f680;</div>
192-
<div class="empty-state-title">No services deployed yet</div>
193-
<div class="empty-state-text">Get started by deploying your first passive income service.</div>
194-
<a href="/setup" class="btn btn-primary btn-lg">Setup Wizard</a>
195-
</div>`;
195+
<table class="breakdown-table">
196+
<thead>
197+
<tr>
198+
<th>Service</th>
199+
<th style="text-align:center;">Status</th>
200+
<th style="text-align:center;">Health</th>
201+
<th style="text-align:right;">Balance</th>
202+
<th style="text-align:right;">Change</th>
203+
<th style="text-align:right;">CPU</th>
204+
<th style="text-align:right;">Memory</th>
205+
<th style="text-align:center;">Payout</th>
206+
<th style="text-align:center;">Actions</th>
207+
</tr>
208+
</thead>
209+
<tbody>${rows}</tbody>
210+
</table>`;
211+
} catch (err) {
212+
container.innerHTML = `<div class="empty-state-text" style="padding:24px 0; text-align:center; color:var(--text-muted);">Failed to load services.</div>`;
196213
}
197214
}
198215

199-
function renderServiceCard(svc) {
216+
function renderServiceRow(svc, bk) {
200217
const statusClass = (svc.container_status || 'stopped').toLowerCase();
201218
const statusLabel = statusClass.charAt(0).toUpperCase() + statusClass.slice(1);
202-
const initial = (svc.name || svc.slug || '?')[0].toUpperCase();
203219

204-
// Health score badge
205-
let healthBadge = '';
220+
// Service name — linked to referral URL if available
221+
const name = escapeHtml(svc.name);
222+
const nameHtml = svc.referral_url
223+
? `<a href="${escapeHtml(svc.referral_url)}" target="_blank" rel="noopener" title="Referral link" style="color:var(--accent); text-decoration:none; font-weight:600;">${name}</a>`
224+
: `<span style="font-weight:600;">${name}</span>`;
225+
226+
// Health badge
227+
let healthBadge = '<span style="color:var(--text-muted);">--</span>';
206228
if (svc.health_score !== null && svc.health_score !== undefined) {
207229
const score = svc.health_score;
208230
const hClass = score >= 80 ? 'badge-running' : score >= 50 ? 'badge-error' : 'badge-stopped';
209-
const uptimeStr = svc.uptime_pct !== null ? ` · ${svc.uptime_pct}% up` : '';
210-
healthBadge = `<span class="badge ${hClass}" title="Health ${score}/100${uptimeStr}" style="margin-left:6px;">${score}</span>`;
231+
healthBadge = `<span class="badge ${hClass}" title="Health ${score}/100">${score}</span>`;
211232
}
212233

213-
// Claim button instead of direct cashout link
214-
let claimBtn = '';
215-
if (svc.cashout && svc.cashout.dashboard_url) {
216-
const minAmount = parseFloat(svc.cashout.min_amount) || 0;
217-
const balance = parseFloat(svc.balance) || 0;
218-
const canCashout = balance >= minAmount && minAmount > 0;
219-
claimBtn = `
220-
<button class="btn btn-sm ${canCashout ? 'btn-success' : 'btn-ghost'}"
221-
onclick="CP.openClaimModal('${svc.slug}')"
222-
title="${canCashout ? 'Eligible for payout' : `Min $${minAmount}`}"
223-
style="font-size:0.75rem; padding:4px 8px;">
224-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
225-
Claim
226-
</button>`;
227-
}
234+
// Balance + delta from breakdown
235+
const balance = (bk && bk.balance) || svc.balance || 0;
236+
const delta = bk ? bk.delta : 0;
237+
const deltaSign = delta > 0 ? '+' : '';
238+
const deltaClass = delta > 0 ? 'positive' : delta < 0 ? 'negative' : '';
239+
const deltaStr = delta !== 0 ? `${deltaSign}${formatCurrency(delta)}` : '--';
228240

229-
return `
230-
<div class="service-card" data-slug="${escapeHtml(svc.slug)}">
231-
<div class="service-card-header">
232-
<div class="service-icon">${initial}</div>
233-
<div>
234-
<div class="service-name">${escapeHtml(svc.name)}${healthBadge}</div>
235-
<span class="badge badge-${statusClass}"><span class="status-dot ${statusClass}"></span> ${statusLabel}</span>
236-
</div>
237-
</div>
238-
<div class="service-stats">
239-
<div class="service-balance">${formatCurrency(svc.balance || 0)}</div>
240-
<div class="service-usage">
241-
<span>CPU ${svc.cpu || '0'}%</span>
242-
<span>MEM ${svc.memory || '0 MB'}</span>
243-
</div>
241+
// Payout progress
242+
const co = svc.cashout || {};
243+
const minAmount = co.min_amount || 0;
244+
const eligible = minAmount > 0 && balance >= minAmount;
245+
const pctToMin = minAmount > 0 ? Math.min(100, (balance / minAmount) * 100) : 0;
246+
const progressBar = minAmount > 0 ? `
247+
<div class="payout-progress" title="${formatCurrency(balance)} / ${formatCurrency(minAmount)}" style="min-width:60px;">
248+
<div class="payout-progress-bar ${eligible ? 'eligible' : ''}" style="width:${pctToMin.toFixed(0)}%"></div>
244249
</div>
245-
<div class="service-actions">
250+
<span class="payout-label">${pctToMin.toFixed(0)}%</span>
251+
` : '<span style="color:var(--text-muted);">--</span>';
252+
253+
// Action buttons
254+
const claimBtn = co.dashboard_url
255+
? `<button class="btn btn-sm ${eligible ? 'btn-success' : 'btn-ghost'} btn-icon" onclick="CP.openClaimModal('${svc.slug}')" title="${eligible ? 'Claim' : 'Details'}">
256+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
257+
</button>`
258+
: '';
259+
260+
return `
261+
<tr class="breakdown-row" data-slug="${escapeHtml(svc.slug)}">
262+
<td>${nameHtml}<div style="font-size:0.7rem; color:var(--text-muted);">${escapeHtml(svc.image || '')}</div></td>
263+
<td style="text-align:center;"><span class="badge badge-${statusClass}"><span class="status-dot ${statusClass}"></span> ${statusLabel}</span></td>
264+
<td style="text-align:center;">${healthBadge}</td>
265+
<td style="text-align:right; font-weight:600;">${formatCurrency(balance)}</td>
266+
<td style="text-align:right;"><span class="stat-change ${deltaClass}">${deltaStr}</span></td>
267+
<td style="text-align:right;">${svc.cpu || '0'}%</td>
268+
<td style="text-align:right;">${svc.memory || '0 MB'}</td>
269+
<td style="text-align:center;">${progressBar}</td>
270+
<td style="text-align:center; white-space:nowrap;">
246271
${claimBtn}
247272
<button class="btn btn-ghost btn-sm btn-icon" onclick="CP.restartService('${svc.slug}')" title="Restart">
248-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
273+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
249274
</button>
250275
<button class="btn btn-ghost btn-sm btn-icon" onclick="CP.stopService('${svc.slug}')" title="Stop">
251-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>
276+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>
252277
</button>
253278
<button class="btn btn-ghost btn-sm btn-icon" onclick="CP.viewLogs('${svc.slug}')" title="Logs">
254-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
279+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
255280
</button>
256-
</div>
257-
</div>`;
281+
</td>
282+
</tr>`;
283+
}
284+
285+
function refreshServices() {
286+
loadServicesTable();
287+
toast('Services refreshed', 'info');
258288
}
259289

260290
async function loadEarningsChart(days) {
@@ -345,82 +375,7 @@ const CP = (() => {
345375
// -----------------------------------------------------------
346376
// Earnings Breakdown
347377
// -----------------------------------------------------------
348-
async function loadEarningsBreakdown() {
349-
const container = document.getElementById('earnings-breakdown');
350-
if (!container) return;
351-
352-
try {
353-
const data = await api('/api/earnings/breakdown');
354-
if (!data || data.length === 0) {
355-
container.innerHTML = '<div class="empty-state-text" style="padding: 16px 0; text-align: center;">No earnings data yet. Configure collectors in Settings.</div>';
356-
return;
357-
}
358-
const rows = data.map(renderBreakdownRow).join('');
359-
container.innerHTML = `
360-
<table class="breakdown-table">
361-
<thead>
362-
<tr>
363-
<th>Service</th>
364-
<th style="text-align:right;">Balance</th>
365-
<th style="text-align:right;">Change</th>
366-
<th style="text-align:center;">Payout</th>
367-
<th style="text-align:center;">Action</th>
368-
</tr>
369-
</thead>
370-
<tbody>${rows}</tbody>
371-
</table>`;
372-
} catch (err) {
373-
container.innerHTML = '<div class="empty-state-text" style="padding: 16px 0; text-align: center;">Could not load breakdown.</div>';
374-
}
375-
}
376-
377-
function renderBreakdownRow(svc) {
378-
const initial = (svc.name || svc.platform || '?')[0].toUpperCase();
379-
const deltaSign = svc.delta > 0 ? '+' : '';
380-
const deltaClass = svc.delta > 0 ? 'positive' : svc.delta < 0 ? 'negative' : '';
381-
const deltaStr = svc.delta !== 0 ? `${deltaSign}${formatCurrency(svc.delta)}` : '--';
382-
383-
const co = svc.cashout || {};
384-
const eligible = co.eligible;
385-
const minAmount = co.min_amount || 0;
386-
const pctToMin = minAmount > 0 ? Math.min(100, (svc.balance / minAmount) * 100) : 0;
387-
388-
// Progress bar toward minimum payout
389-
const progressBar = minAmount > 0 ? `
390-
<div class="payout-progress" title="${formatCurrency(svc.balance)} / ${formatCurrency(minAmount)}">
391-
<div class="payout-progress-bar ${eligible ? 'eligible' : ''}" style="width:${pctToMin.toFixed(0)}%"></div>
392-
</div>
393-
<span class="payout-label">${formatCurrency(svc.balance)} / ${formatCurrency(minAmount)}</span>
394-
` : '<span class="payout-label" style="color:var(--text-muted);">N/A</span>';
395-
396-
const claimBtn = co.dashboard_url
397-
? `<button class="btn btn-sm ${eligible ? 'btn-success' : 'btn-ghost'}" onclick="CP.openClaimModal('${escapeHtml(svc.platform)}')" ${!eligible ? 'title="Below minimum payout"' : ''}>
398-
${eligible ? 'Claim' : 'Details'}
399-
</button>`
400-
: '';
401-
402-
return `
403-
<tr class="breakdown-row">
404-
<td>
405-
<div style="display:flex; align-items:center; gap:10px;">
406-
<div class="service-icon" style="width:32px;height:32px;font-size:0.9rem;">${initial}</div>
407-
<div>
408-
<div style="font-weight:600; font-size:0.9rem;">${escapeHtml(svc.name)}</div>
409-
<div style="font-size:0.75rem; color:var(--text-muted);">${escapeHtml(svc.currency)}</div>
410-
</div>
411-
</div>
412-
</td>
413-
<td style="text-align:right; font-weight:600; font-size:0.95rem;">${formatCurrency(svc.balance)}</td>
414-
<td style="text-align:right;"><span class="stat-change ${deltaClass}">${deltaStr}</span></td>
415-
<td style="text-align:center;">${progressBar}</td>
416-
<td style="text-align:center;">${claimBtn}</td>
417-
</tr>`;
418-
}
419-
420-
function refreshBreakdown() {
421-
loadEarningsBreakdown();
422-
toast('Breakdown refreshed', 'info');
423-
}
378+
// loadEarningsBreakdown merged into loadServicesTable above
424379

425380
// -----------------------------------------------------------
426381
// Claim Modal
@@ -519,7 +474,7 @@ const CP = (() => {
519474
try {
520475
await api(`/api/services/${slug}/restart`, { method: 'POST' });
521476
toast(`${slug} restarting...`, 'success');
522-
loadDashboardServices();
477+
loadServicesTable();
523478
} catch (err) {
524479
toast(err.message, 'error');
525480
}
@@ -529,7 +484,7 @@ const CP = (() => {
529484
try {
530485
await api(`/api/services/${slug}/stop`, { method: 'POST' });
531486
toast(`${slug} stopped`, 'success');
532-
loadDashboardServices();
487+
loadServicesTable();
533488
} catch (err) {
534489
toast(err.message, 'error');
535490
}
@@ -539,7 +494,7 @@ const CP = (() => {
539494
try {
540495
await api(`/api/services/${slug}/start`, { method: 'POST' });
541496
toast(`${slug} starting...`, 'success');
542-
loadDashboardServices();
497+
loadServicesTable();
543498
} catch (err) {
544499
toast(err.message, 'error');
545500
}
@@ -550,7 +505,7 @@ const CP = (() => {
550505
try {
551506
await api(`/api/services/${slug}`, { method: 'DELETE' });
552507
toast(`${slug} removed`, 'success');
553-
loadDashboardServices();
508+
loadServicesTable();
554509
} catch (err) {
555510
toast(err.message, 'error');
556511
}
@@ -1310,7 +1265,7 @@ const CP = (() => {
13101265
testCollectors,
13111266
editCredentials,
13121267
filterCatalog,
1313-
refreshBreakdown,
1268+
refreshServices,
13141269
openClaimModal,
13151270
};
13161271
})();

app/templates/dashboard.html

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,19 @@
4141
</div>
4242
</div>
4343

44-
<!-- Earnings Breakdown -->
44+
<!-- Deployed Services Table -->
4545
<div class="card" style="margin-bottom: 24px;">
4646
<div class="card-header">
47-
<span class="card-title">Earnings Breakdown</span>
48-
<button class="btn btn-ghost btn-sm" onclick="CP.refreshBreakdown()" title="Refresh">
49-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
50-
</button>
47+
<span class="card-title">Deployed Services</span>
48+
<div style="display:flex; gap:8px; align-items:center;">
49+
<button class="btn btn-ghost btn-sm" onclick="CP.refreshServices()" title="Refresh">
50+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
51+
</button>
52+
<a href="/setup" class="btn btn-primary btn-sm">+ Add Service</a>
53+
</div>
5154
</div>
52-
<div id="earnings-breakdown">
53-
<div class="empty-state-text" style="padding: 16px 0; text-align: center; color: var(--text-muted);">Loading...</div>
55+
<div id="services-table-container" style="overflow-x:auto;">
56+
<div class="empty-state-text" style="padding: 24px 0; text-align: center; color: var(--text-muted);">Loading...</div>
5457
</div>
5558
</div>
5659

@@ -68,20 +71,4 @@ <h3 class="modal-title" id="claim-modal-title">Claim Earnings</h3>
6871
</div>
6972
</div>
7073
</div>
71-
72-
<!-- Service Cards -->
73-
<div class="section-header">
74-
<h2 class="section-title">Deployed Services</h2>
75-
<a href="/setup" class="btn btn-primary btn-sm">+ Add Service</a>
76-
</div>
77-
78-
<div class="services-grid" id="services-list">
79-
<!-- Populated by JS -->
80-
<div class="empty-state">
81-
<div class="empty-state-icon">&#x1f680;</div>
82-
<div class="empty-state-title">No services deployed yet</div>
83-
<div class="empty-state-text">Get started by deploying your first passive income service.</div>
84-
<a href="/setup" class="btn btn-primary btn-lg">Setup Wizard</a>
85-
</div>
86-
</div>
8774
{% endblock %}

0 commit comments

Comments
 (0)