Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 63 additions & 21 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,21 @@ func (a *App) Shutdown(_ context.Context) {
}

type AppState struct {
Config config.AppConfig `json:"config"`
Runtime runtime.Status `json:"runtime"`
Services []catalog.Service `json:"services"`
Deployments []store.Deployment `json:"deployments"`
Earnings []store.EarningsRecord `json:"earnings"`
Guides []runtime.InstallGuide `json:"guides"`
Notifications []Notification `json:"notifications"`
Currencies []string `json:"currencies"`
Summary EarningsSummary `json:"summary"`
Health map[string]store.HealthScore `json:"health"`
Config config.AppConfig `json:"config"`
Runtime runtime.Status `json:"runtime"`
Services []catalog.Service `json:"services"`
Deployments []store.Deployment `json:"deployments"`
// OutdatedServices lists the slugs of deployed services whose running image no
// longer matches the catalog's current image (the provider changed or re-pinned
// it): deployed and often still "running", but likely earning nothing until
// re-deployed. The frontend badges these; notifications() also alerts on them.
OutdatedServices []string `json:"outdatedServices"`
Earnings []store.EarningsRecord `json:"earnings"`
Guides []runtime.InstallGuide `json:"guides"`
Notifications []Notification `json:"notifications"`
Currencies []string `json:"currencies"`
Summary EarningsSummary `json:"summary"`
Health map[string]store.HealthScore `json:"health"`
// ServiceDetails carries each collector's optional per-service JSON detail blob
// keyed by slug (e.g. the MystNodes per-node earnings breakdown). The frontend
// parses the raw JSON per service; the backend stores and forwards it opaquely.
Expand Down Expand Up @@ -357,17 +362,18 @@ func (a *App) GetAppState() (AppState, error) {
deployments := a.store.ListDeployments()
earnings := a.store.ListLatestEarnings()
return AppState{
Config: a.cfg.Config(),
Runtime: runtimeStatus,
Services: a.catalog.ListVisible(),
Deployments: deployments,
Earnings: earnings,
Guides: runtime.InstallGuides(),
Notifications: a.notifications(runtimeStatus, earnings, deployments),
Currencies: supportedCurrencies(),
Summary: a.computeEarningsSummary(earnings),
Health: a.store.HealthScores(7),
ServiceDetails: a.store.ListServiceDetails(),
Config: a.cfg.Config(),
Runtime: runtimeStatus,
Services: a.catalog.ListVisible(),
Deployments: deployments,
OutdatedServices: a.outdatedServices(deployments),
Earnings: earnings,
Guides: runtime.InstallGuides(),
Notifications: a.notifications(runtimeStatus, earnings, deployments),
Currencies: supportedCurrencies(),
Summary: a.computeEarningsSummary(earnings),
Health: a.store.HealthScores(7),
ServiceDetails: a.store.ListServiceDetails(),
}, nil
}

Expand Down Expand Up @@ -1264,6 +1270,20 @@ func (a *App) notifications(status runtime.Status, earnings []store.EarningsReco
if !status.Available && !status.NativeAvailable {
items = append(items, Notification{Level: "warning", Title: "Runtime offline", Message: status.Message})
}
// A container whose image no longer matches the catalog often keeps running (so it
// looks healthy) while the retired provider client earns nothing — surface it so the
// user re-deploys instead of trusting the green status.
for _, slug := range a.outdatedServices(deployments) {
name := slug
if svc, ok := a.catalog.Get(slug); ok {
name = svc.Name
}
items = append(items, Notification{
Level: "warning",
Title: name + " update available",
Message: "The provider changed this service's image. Re-deploy it from the catalog so it keeps earning.",
})
}
for _, record := range earnings {
if record.Error != "" {
items = append(items, Notification{Level: "error", Title: record.Platform + " collector", Message: record.Error})
Expand All @@ -1275,6 +1295,28 @@ func (a *App) notifications(status runtime.Status, earnings []store.EarningsReco
return items
}

// outdatedServices returns the slugs of deployed services whose running image no
// longer matches the catalog's current image for that service. Such a container
// usually still runs — and so looks healthy — while earning nothing, so the UI
// flags it for re-deploy. Services absent from the catalog, or with an empty image
// on either side, are conservatively not flagged (see catalog.ImageOutdated).
func (a *App) outdatedServices(deployments []store.Deployment) []string {
if a.catalog == nil {
return nil
}
var out []string
for _, dep := range deployments {
svc, ok := a.catalog.Get(dep.Slug)
if !ok {
continue
}
if catalog.ImageOutdated(dep.Image, svc.Docker.Image) {
out = append(out, dep.Slug)
}
}
return out
}

func deploymentSlugs(deployments []store.Deployment) []string {
out := make([]string, 0, len(deployments))
for _, dep := range deployments {
Expand Down
55 changes: 55 additions & 0 deletions app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import (
"math"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/fstest"
"time"

"github.com/GeiserX/CashPilot-Desktop/internal/catalog"
"github.com/GeiserX/CashPilot-Desktop/internal/config"
"github.com/GeiserX/CashPilot-Desktop/internal/exchange"
"github.com/GeiserX/CashPilot-Desktop/internal/runtime"
"github.com/GeiserX/CashPilot-Desktop/internal/store"
)

Expand Down Expand Up @@ -607,3 +610,55 @@ func TestCollectAllSingleFlight(t *testing.T) {
t.Fatalf("bounded concurrency violated: max concurrent collects = %d, want <= %d", got, collectConcurrency)
}
}

// outdatedTestCatalog builds a one-service catalog whose current image is a
// specific digest-pinned reference, so image-drift can be asserted deterministically.
func outdatedTestCatalog(t *testing.T) *catalog.Catalog {
t.Helper()
cat, err := catalog.LoadEmbedded(fstest.MapFS{
"services/bandwidth/example.yml": {Data: []byte(
"name: Example\nslug: example\ncategory: bandwidth\nstatus: active\ndocker:\n image: ghcr.io/org/new-cli@sha256:new\n")},
})
if err != nil {
t.Fatalf("catalog.LoadEmbedded error: %v", err)
}
return cat
}

// TestOutdatedServices verifies that a deployment whose image drifted from the
// catalog is flagged, an in-catalog match is not, and an unknown slug is ignored.
func TestOutdatedServices(t *testing.T) {
app := &App{catalog: outdatedTestCatalog(t)}

drifted := []store.Deployment{
{Slug: "example", Image: "old/legacy@sha256:old"}, // provider changed the repo -> outdated
{Slug: "unknown", Image: "whatever:1.0"}, // not in the catalog -> ignored
}
got := app.outdatedServices(drifted)
if len(got) != 1 || got[0] != "example" {
t.Fatalf("outdatedServices(drifted) = %v, want [example]", got)
}

matching := []store.Deployment{{Slug: "example", Image: "ghcr.io/org/new-cli@sha256:new"}}
if got := app.outdatedServices(matching); len(got) != 0 {
t.Fatalf("outdatedServices(matching) = %v, want empty", got)
}
}

// TestNotificationsFlagsOutdated verifies drift surfaces as an actionable warning.
func TestNotificationsFlagsOutdated(t *testing.T) {
app := &App{catalog: outdatedTestCatalog(t)}
deps := []store.Deployment{{Slug: "example", Image: "old/legacy@sha256:old"}}
// Runtime available so the offline warning does not appear and only drift is under test.
items := app.notifications(runtime.Status{Available: true}, nil, deps)

found := false
for _, it := range items {
if it.Level == "warning" && strings.Contains(it.Title, "update available") {
found = true
}
}
if !found {
t.Fatalf("notifications() did not include an 'update available' warning: %+v", items)
}
}
13 changes: 8 additions & 5 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ function renderDashboard(current: AppState) {
</div>
</div>
<div class="services-table-wrap">
${renderServicesTable(services, deployments, earnings, current.health, current.serviceDetails)}
${renderServicesTable(services, deployments, earnings, current.health, current.serviceDetails, current.outdatedServices)}
</div>
</section>
<pre id="service-output" class="output dashboard-output"></pre>
Expand Down Expand Up @@ -438,7 +438,7 @@ function renderCatalog(current: AppState) {
${categories.map((category) => `<button class="filter-tab ${catalogFilter === category ? "active" : ""}" data-filter="${category}">${escapeHtml(capitalize(category))}</button>`).join("")}
</div>
<div class="catalog-grid">
${services.map((service) => renderCatalogCard(service, current.deployments || [])).join("")}
${services.map((service) => renderCatalogCard(service, current.deployments || [], current.outdatedServices)).join("")}
</div>
</section>
</main>
Expand Down Expand Up @@ -469,8 +469,9 @@ function renderCatalog(current: AppState) {
});
}

function renderCatalogCard(service: Service, deployments: Deployment[]) {
function renderCatalogCard(service: Service, deployments: Deployment[], outdated: string[] | null) {
const deployed = deployments.some((deployment) => deployment.slug === service.slug);
const isOutdated = deployed && (outdated || []).includes(service.slug);
const signupUrl = service.referral?.signupUrl || service.website;
return `
<article class="catalog-card">
Expand All @@ -481,6 +482,7 @@ function renderCatalogCard(service: Service, deployments: Deployment[]) {
<div class="badge-row">
<span class="badge">${escapeHtml(service.category)}</span>
<span class="badge ${deployed ? "success" : ""}">${deployed ? "Deployed" : service.manualOnly ? "Manual" : "Available"}</span>
${isOutdated ? `<span class="badge warn" title="The provider changed this service's image. Re-deploy from the catalog to keep earning.">update available</span>` : ""}
</div>
</div>
</div>
Expand Down Expand Up @@ -991,7 +993,7 @@ function renderMystNodes(json: string | undefined): string {
`;
}

function renderServicesTable(services: Service[], deployments: Deployment[], earnings: {platform: string; balance: number; currency: string; error?: string}[], health: Record<string, HealthScore> | null, serviceDetails: Record<string, string> | null) {
function renderServicesTable(services: Service[], deployments: Deployment[], earnings: {platform: string; balance: number; currency: string; error?: string}[], health: Record<string, HealthScore> | null, serviceDetails: Record<string, string> | null, outdated: string[] | null) {
if (deployments.length === 0) {
return `
<div class="empty-state">
Expand All @@ -1002,6 +1004,7 @@ function renderServicesTable(services: Service[], deployments: Deployment[], ear
`;
}
const earningBySlug = new Map(earnings.map((record) => [record.platform, record]));
const outdatedSet = new Set(outdated || []);
return `
<table class="services-table">
<thead>
Expand All @@ -1025,7 +1028,7 @@ function renderServicesTable(services: Service[], deployments: Deployment[], ear
<strong>${escapeHtml(service?.name || deployment.slug)}</strong>
<small>${escapeHtml(deployment.image)}</small>
</td>
<td><span class="status-pill ${deployment.status === "running" ? "ok" : "warn"}">${escapeHtml(deployment.status)}</span>${renderHealthBadge(health?.[deployment.slug])}</td>
<td><span class="status-pill ${deployment.status === "running" ? "ok" : "warn"}">${escapeHtml(deployment.status)}</span>${renderHealthBadge(health?.[deployment.slug])}${outdatedSet.has(deployment.slug) ? ` <span class="badge warn" title="The provider changed this service's image. Re-deploy from the catalog to keep earning.">update available</span>` : ""}</td>
<td>${earning && !earning.error ? formatBalance(earning.balance, earning.currency) : "<span class=\"muted\">--</span>"}</td>
<td>${deployment.cpuPercent.toFixed(1)}%</td>
<td>${deployment.memoryMb.toFixed(0)} MB</td>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/wails.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export interface AppState {
runtime: RuntimeStatus;
services: Service[];
deployments: Deployment[] | null;
// Slugs of deployed services whose running image no longer matches the catalog
// (provider changed/re-pinned it): deployed but likely earning nothing.
outdatedServices: string[] | null;
health: Record<string, HealthScore> | null;
earnings: EarningsRecord[] | null;
guides: InstallGuide[];
Expand Down
33 changes: 33 additions & 0 deletions internal/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,39 @@ func (c *Catalog) Get(slug string) (Service, bool) {
return svc, ok
}

// splitImage splits a Docker image reference into repository, tag, and digest.
// A ':' is a tag only when it comes after the last '/'; before it, it is a
// registry port (e.g. localhost:5000/img).
func splitImage(ref string) (repo, tag, digest string) {
if i := strings.Index(ref, "@"); i >= 0 {
ref, digest = ref[:i], ref[i+1:]
}
repo = ref
if lastColon := strings.LastIndex(ref, ":"); lastColon > strings.LastIndex(ref, "/") {
repo, tag = ref[:lastColon], ref[lastColon+1:]
}
return repo, tag, digest
}

// ImageOutdated reports whether a running container's image no longer matches the
// catalog entry it was deployed from. It is true when the provider changed the
// image path (the ProxyBase migration) or the catalog re-pinned to a new digest,
// so the UI can prompt a re-deploy instead of showing a healthy-looking container
// that is silently running a retired image and earning nothing. Deliberately
// conservative: unknown/empty images and a pure tag-vs-digest difference of the
// same repository are NOT flagged.
func ImageOutdated(deployed, catalogImage string) bool {
if deployed == "" || catalogImage == "" {
return false
}
dRepo, _, dDigest := splitImage(deployed)
cRepo, _, cDigest := splitImage(catalogImage)
if dRepo != cRepo {
return true
}
return cDigest != "" && dDigest != "" && cDigest != dDigest
}

func locateServicesDir() (string, error) {
candidates := []string{
filepath.Join("services"),
Expand Down
35 changes: 35 additions & 0 deletions internal/catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,38 @@ native:
t.Fatal("NativeBinaryFor should report no binary for an undeclared os/arch")
}
}

func TestSplitImage(t *testing.T) {
cases := []struct{ ref, repo, tag, digest string }{
{"ghcr.io/proxybaseorg/peer-cli@sha256:abc", "ghcr.io/proxybaseorg/peer-cli", "", "sha256:abc"},
{"proxybase/proxybase:latest", "proxybase/proxybase", "latest", ""},
{"repo", "repo", "", ""},
{"localhost:5000/img:1.0", "localhost:5000/img", "1.0", ""},
}
for _, c := range cases {
repo, tag, digest := splitImage(c.ref)
if repo != c.repo || tag != c.tag || digest != c.digest {
t.Errorf("splitImage(%q) = (%q,%q,%q), want (%q,%q,%q)", c.ref, repo, tag, digest, c.repo, c.tag, c.digest)
}
}
}

func TestImageOutdated(t *testing.T) {
cases := []struct {
name, deployed, catalog string
want bool
}{
{"provider migrated the image path", "proxybase/proxybase@sha256:old", "ghcr.io/proxybaseorg/peer-cli@sha256:new", true},
{"catalog re-pinned to a new digest", "repo@sha256:aaa", "repo@sha256:bbb", true},
{"identical image", "repo@sha256:aaa", "repo@sha256:aaa", false},
{"empty deployed image", "", "repo@sha256:x", false},
{"empty catalog image", "repo:1.0", "", false},
{"tag vs digest, same repo (unresolvable)", "repo:1.0", "repo@sha256:x", false},
{"tag-only difference, same repo", "repo:1.0", "repo:2.0", false},
}
for _, c := range cases {
if got := ImageOutdated(c.deployed, c.catalog); got != c.want {
t.Errorf("%s: ImageOutdated(%q, %q) = %v, want %v", c.name, c.deployed, c.catalog, got, c.want)
}
}
}
Loading