Skip to content

Commit bb4c0de

Browse files
committed
Aggregate services by slug, show instance count and external services
- Health check deduplicates by slug (best status wins) - Dashboard shows one row per service with Nx instance badge - External services (Grass, Bytelixir) appear without Docker stats - Google Fonts loads async to avoid render-blocking - Removed duplicate m4b containers from all servers
1 parent ff95645 commit bb4c0de

4 files changed

Lines changed: 147 additions & 25 deletions

File tree

app/main.py

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,22 @@
4141

4242

4343
async def _run_health_check() -> None:
44-
"""Check health of all deployed containers and record events."""
44+
"""Check health of all deployed containers and record events.
45+
46+
Deduplicates by slug: if *any* instance of a service is running,
47+
record a single check_ok for that slug (avoids penalising services
48+
deployed on multiple nodes where one may be stopped).
49+
"""
4550
try:
4651
statuses = orchestrator.get_status()
52+
# Aggregate: slug -> best status (running wins)
53+
slug_best: dict[str, str] = {}
4754
for s in statuses:
4855
slug = s["slug"]
4956
status = s.get("status", "unknown")
57+
if slug_best.get(slug) != "running":
58+
slug_best[slug] = status
59+
for slug, status in slug_best.items():
5060
if status == "running":
5161
await database.record_health_event(slug, "check_ok")
5262
else:
@@ -431,13 +441,37 @@ async def api_list_services(request: Request) -> list[dict[str, Any]]:
431441

432442
@app.get("/api/services/deployed")
433443
async def api_services_deployed(request: Request) -> list[dict[str, Any]]:
434-
"""Return deployed services with container status, balance, CPU, memory."""
444+
"""Return deployed services with container status, balance, CPU, memory.
445+
446+
Multiple containers for the same slug (multi-node) are aggregated into a
447+
single row with summed CPU/memory and an instance count.
448+
"""
435449
_require_auth_api(request)
436450
try:
437451
statuses = orchestrator.get_status_cached()
438452
except RuntimeError:
439453
statuses = []
440454

455+
# Also include worker containers
456+
workers = await database.list_workers()
457+
for w in workers:
458+
if w.get("status") != "online":
459+
continue
460+
containers = json.loads(w.get("containers", "[]"))
461+
for c in containers:
462+
slug = c.get("slug", "")
463+
if slug:
464+
statuses.append({
465+
"slug": slug,
466+
"name": c.get("name", slug),
467+
"status": c.get("status", "unknown"),
468+
"image": c.get("image", ""),
469+
"cpu_percent": c.get("cpu_percent", 0),
470+
"memory_mb": c.get("memory_mb", 0),
471+
"category": "",
472+
"deployed_by": w.get("name", "worker"),
473+
})
474+
441475
# Get latest earnings per platform for balance display
442476
earnings = await database.get_earnings_summary()
443477
balance_map = {e["platform"]: e["balance"] for e in earnings}
@@ -446,23 +480,44 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]:
446480
health_scores = await database.get_health_scores(7)
447481
health_map = {h["slug"]: h for h in health_scores}
448482

449-
result = []
483+
# Aggregate by slug: one row per service
484+
_STATUS_PRIORITY = {"running": 0, "restarting": 1, "exited": 2, "created": 3, "dead": 4}
485+
slug_agg: dict[str, dict[str, Any]] = {}
450486
for s in statuses:
451487
slug = s["slug"]
488+
if slug not in slug_agg:
489+
slug_agg[slug] = {
490+
"instances": [],
491+
"total_cpu": 0.0,
492+
"total_mem": 0.0,
493+
"best_status": s.get("status", "unknown"),
494+
"image": s.get("image", ""),
495+
}
496+
agg = slug_agg[slug]
497+
agg["instances"].append(s)
498+
agg["total_cpu"] += float(s.get("cpu_percent", 0))
499+
agg["total_mem"] += float(s.get("memory_mb", 0))
500+
cur = s.get("status", "unknown")
501+
if _STATUS_PRIORITY.get(cur, 9) < _STATUS_PRIORITY.get(agg["best_status"], 9):
502+
agg["best_status"] = cur
503+
504+
result = []
505+
for slug, agg in slug_agg.items():
452506
svc = catalog.get_service(slug)
453507
health = health_map.get(slug, {})
454508
entry = {
455509
"slug": slug,
456510
"name": svc["name"] if svc else slug,
457-
"container_status": s["status"],
511+
"container_status": agg["best_status"],
458512
"balance": balance_map.get(slug, 0.0),
459-
"cpu": f"{s.get('cpu_percent', 0)}",
460-
"memory": f"{s.get('memory_mb', 0)} MB",
461-
"image": s.get("image", ""),
462-
"category": s.get("category", ""),
513+
"cpu": f"{agg['total_cpu']:.2f}",
514+
"memory": f"{agg['total_mem']:.1f} MB",
515+
"image": agg["image"],
516+
"category": agg["instances"][0].get("category", ""),
463517
"health_score": health.get("score"),
464518
"uptime_pct": health.get("uptime_pct"),
465519
"restarts_7d": health.get("restarts", 0),
520+
"instances": len(agg["instances"]),
466521
}
467522
if svc:
468523
cashout = svc.get("cashout", {})
@@ -472,6 +527,41 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]:
472527
if referral:
473528
entry["referral_url"] = referral.get("signup_url", "")
474529
result.append(entry)
530+
531+
# Include external services (no Docker container, e.g. Grass, Bytelixir)
532+
seen_slugs = {r["slug"] for r in result}
533+
deployments = await database.get_deployments()
534+
for d in deployments:
535+
slug = d["slug"]
536+
if slug in seen_slugs:
537+
continue
538+
if d.get("status") != "external":
539+
continue
540+
svc = catalog.get_service(slug)
541+
health = health_map.get(slug, {})
542+
entry = {
543+
"slug": slug,
544+
"name": svc["name"] if svc else slug,
545+
"container_status": "external",
546+
"balance": balance_map.get(slug, 0.0),
547+
"cpu": "",
548+
"memory": "",
549+
"image": "",
550+
"category": svc.get("category", "") if svc else "",
551+
"health_score": health.get("score"),
552+
"uptime_pct": health.get("uptime_pct"),
553+
"restarts_7d": 0,
554+
"instances": 0,
555+
}
556+
if svc:
557+
cashout = svc.get("cashout", {})
558+
if cashout:
559+
entry["cashout"] = cashout
560+
referral = svc.get("referral", {})
561+
if referral:
562+
entry["referral_url"] = referral.get("signup_url", "")
563+
result.append(entry)
564+
475565
return result
476566

477567

app/static/css/style.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,18 @@ img { max-width: 100%; }
730730
}
731731
.status-dot.stopped { background: var(--error); }
732732
.status-dot.error { background: var(--warning); }
733+
.status-dot.external { background: var(--accent-secondary); }
734+
.badge-external {
735+
background: var(--accent-secondary-soft);
736+
color: var(--accent-secondary);
737+
}
738+
.badge-instances {
739+
background: var(--bg-tertiary);
740+
color: var(--text-secondary);
741+
font-size: 0.7rem;
742+
margin-left: 4px;
743+
vertical-align: middle;
744+
}
733745

734746
@keyframes pulse-glow {
735747
0%, 100% { box-shadow: 0 0 4px rgba(34, 197, 94, 0.4); }

app/static/js/app.js

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -234,15 +234,27 @@ const CP = (() => {
234234
}
235235

236236
function renderServiceRow(svc, bk) {
237-
const statusClass = (svc.container_status || 'stopped').toLowerCase();
238-
const statusLabel = statusClass.charAt(0).toUpperCase() + statusClass.slice(1);
237+
const isExternal = svc.container_status === 'external';
238+
const statusClass = isExternal ? 'external' : (svc.container_status || 'stopped').toLowerCase();
239+
const statusLabel = isExternal ? 'External' : statusClass.charAt(0).toUpperCase() + statusClass.slice(1);
239240

240241
// Service name — linked to referral URL if available
241242
const name = escapeHtml(svc.name);
242243
const nameHtml = svc.referral_url
243244
? `<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>`
244245
: `<span style="font-weight:600;">${name}</span>`;
245246

247+
// Instance count badge
248+
const instances = svc.instances || 0;
249+
const instanceBadge = instances > 1
250+
? `<span class="badge badge-instances" title="${instances} instances">${instances}x</span>`
251+
: '';
252+
253+
// Subtitle: image for Docker, empty for external
254+
const subtitle = svc.image
255+
? escapeHtml(svc.image)
256+
: (isExternal ? 'App / Browser' : '');
257+
246258
// Health badge
247259
let healthBadge = '<span style="color:var(--text-muted);">--</span>';
248260
if (svc.health_score !== null && svc.health_score !== undefined) {
@@ -258,6 +270,10 @@ const CP = (() => {
258270
const deltaClass = delta > 0 ? 'positive' : delta < 0 ? 'negative' : '';
259271
const deltaStr = delta !== 0 ? `${deltaSign}${formatCurrency(delta)}` : '--';
260272

273+
// CPU/Memory — skip for external
274+
const cpuStr = isExternal ? '--' : `${svc.cpu || '0'}%`;
275+
const memStr = isExternal ? '--' : (svc.memory || '0 MB');
276+
261277
// Payout progress
262278
const co = svc.cashout || {};
263279
const minAmount = co.min_amount || 0;
@@ -270,7 +286,7 @@ const CP = (() => {
270286
<span class="payout-label">${pctToMin.toFixed(0)}%</span>
271287
` : '<span style="color:var(--text-muted);">--</span>';
272288

273-
// Action buttons — always render cashout button for consistent width
289+
// Action buttons — hide container controls for external services
274290
const claimTitle = co.dashboard_url
275291
? (eligible ? 'Cash out earnings' : 'View payout details')
276292
: 'No payout info available';
@@ -279,27 +295,30 @@ const CP = (() => {
279295
<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>
280296
</button>`;
281297

298+
const containerBtns = isExternal ? '' : `
299+
<button class="btn btn-ghost btn-sm btn-icon" onclick="CP.restartService('${svc.slug}')" title="Restart container">
300+
<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>
301+
</button>
302+
<button class="btn btn-ghost btn-sm btn-icon" onclick="CP.stopService('${svc.slug}')" title="Stop container">
303+
<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>
304+
</button>
305+
<button class="btn btn-ghost btn-sm btn-icon" onclick="CP.viewLogs('${svc.slug}')" title="View container logs">
306+
<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>
307+
</button>`;
308+
282309
return `
283310
<tr class="breakdown-row" data-slug="${escapeHtml(svc.slug)}">
284-
<td>${nameHtml}<div style="font-size:0.7rem; color:var(--text-muted);">${escapeHtml(svc.image || '')}</div></td>
311+
<td>${nameHtml} ${instanceBadge}<div style="font-size:0.7rem; color:var(--text-muted);">${subtitle}</div></td>
285312
<td style="text-align:center;"><span class="badge badge-${statusClass}"><span class="status-dot ${statusClass}"></span> ${statusLabel}</span></td>
286313
<td style="text-align:center;">${healthBadge}</td>
287314
<td style="text-align:right; font-weight:600;">${formatCurrency(balance)}</td>
288315
<td style="text-align:right;"><span class="stat-change ${deltaClass}">${deltaStr}</span></td>
289-
<td style="text-align:right;">${svc.cpu || '0'}%</td>
290-
<td style="text-align:right;">${svc.memory || '0 MB'}</td>
316+
<td style="text-align:right;">${cpuStr}</td>
317+
<td style="text-align:right;">${memStr}</td>
291318
<td style="text-align:center;">${progressBar}</td>
292319
<td style="text-align:center; white-space:nowrap;">
293320
${claimBtn}
294-
<button class="btn btn-ghost btn-sm btn-icon" onclick="CP.restartService('${svc.slug}')" title="Restart container">
295-
<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>
296-
</button>
297-
<button class="btn btn-ghost btn-sm btn-icon" onclick="CP.stopService('${svc.slug}')" title="Stop container">
298-
<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>
299-
</button>
300-
<button class="btn btn-ghost btn-sm btn-icon" onclick="CP.viewLogs('${svc.slug}')" title="View container logs">
301-
<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>
302-
</button>
321+
${containerBtns}
303322
</td>
304323
</tr>`;
305324
}

app/templates/base.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
99
<link rel="preconnect" href="https://fonts.googleapis.com">
1010
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11-
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
1211
<link rel="stylesheet" href="/static/css/style.css">
12+
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" onload="this.onload=null;this.rel='stylesheet'">
13+
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"></noscript>
1314
<script>
1415
(function(){var t=localStorage.getItem('cp-theme')||'dark';document.documentElement.setAttribute('data-theme',t)})();
1516
</script>
@@ -175,7 +176,7 @@ <h3 class="modal-title" id="service-detail-title">Service</h3>
175176
</div>
176177
</div>
177178

178-
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
179+
<script async src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
179180
<script src="/static/js/app.js"></script>
180181
{% block scripts %}{% endblock %}
181182
</body>

0 commit comments

Comments
 (0)