diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a2a4de..6daed6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A paired Desktop shows the account-wide picture, and an unlinked one goes back to its own.** While paired, the dashboard gains an "Across your CashPilot account" panel with what the platforms this machine runs earned on your provider accounts over the server's reporting window, straight from the heartbeat response. Unlink and it disappears, leaving exactly the local numbers as before — which works because pairing COPIES this machine's history upstream rather than moving it. + + Two things it is careful about. A platform the server has no reading for renders as **—**, never `0.00`: no reading usually means a collector that does not exist yet or credentials nobody entered, and showing zero would report a loss that did not happen. And a platform running on more than one machine is marked **shared**, because earnings are collected per platform from the provider — if two machines run the same service the provider reports one balance and nothing can split it, so the figure is the account's rather than this machine's. + - **Pairing hands the server the history collected before it.** A Desktop that ran standalone for months and was then paired used to appear on the fleet page starting from the day of pairing — every earlier day it had recorded was simply absent from the total, with no way to get it there. The first time a CashPilot server confirms this worker, Desktop now uploads its recorded daily balances to `POST /api/workers/earnings-import` (requires CashPilot v1.16.0 or newer). It is a **copy, not a migration**: the local rows are read and left exactly where they are, so unlinking leaves this machine still showing precisely what it earned on its own. The server files the readings under this client's own source rather than merging them into its own series, because earnings are clamped deltas between consecutive balance readings — interleaving two samplers of one provider account makes every apparent drop clamp to zero and understates the total. Separate series are differenced separately and then summed. diff --git a/app.go b/app.go index 435d380..226e818 100644 --- a/app.go +++ b/app.go @@ -232,6 +232,11 @@ type AppState struct { // 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. ServiceDetails map[string]string `json:"serviceDetails"` + // Fleet is the server's account-level view of the platforms this machine + // runs, present ONLY while paired and only once the server has reported. + // Nil means show the local numbers alone -- which is standalone, and is also + // the state a machine returns to after unlinking. + Fleet *FleetView `json:"fleet"` // Hostname is this machine's name, so a deploy form can render a {hostname}-defaulted // field with the real value the deploy path will substitute (instead of the literal // "cashpilot-{hostname}" the raw catalog default would otherwise show and submit). @@ -387,6 +392,7 @@ func (a *App) GetAppState() (AppState, error) { Summary: a.computeEarningsSummary(earnings), Health: a.store.HealthScores(7), ServiceDetails: a.store.ListServiceDetails(), + Fleet: a.fleetView(), Hostname: runtime.DeviceHostname(), }, nil } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 84eb1e9..8495f63 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -29,7 +29,7 @@ import { StartService, StopService, } from "../wailsjs/go/main/App"; -import type { AppState, BackgroundStatus, DailyPoint, Deployment, FleetState, HealthScore, InstallGuide, MystNode, PointsBalance, Service, ServiceEarning, SettingsState } from "./wails"; +import type { AppState, BackgroundStatus, DailyPoint, Deployment, FleetState, FleetView, HealthScore, InstallGuide, MystNode, PointsBalance, Service, ServiceEarning, SettingsState } from "./wails"; let state: AppState | null = null; let selectedService: Service | null = null; @@ -250,6 +250,8 @@ function renderDashboard(current: AppState) { ${points.length ? renderPointsSection(points) : ""} + ${current.fleet ? renderFleetSection(current.fleet) : ""} +
Deployed Services @@ -877,6 +879,75 @@ function changeCaption(pct: number, suffix: string) { return `${arrow} ${Math.abs(pct).toFixed(1)}% ${suffix}`; } +// The account-level picture, shown only while paired with a CashPilot server. +// +// It sits BELOW the local numbers rather than replacing them, and it is labelled +// as the account's rather than this machine's, because those are different +// claims. Earnings are collected per PLATFORM from the provider; if two machines +// run the same service the provider reports one balance and nothing can split +// it. Saying "this machine earned X" would be false in exactly the case a fleet +// user is in. +function renderFleetSection(fleet: FleetView) { + const platforms = fleet.platforms || []; + const withoutReadings = fleet.withoutReadings || []; + const shared = platforms.filter((p) => p.shared).length; + // null is UNKNOWN, and it renders as a dash. `?? 0` here would report a loss + // that did not happen -- most convincingly to the user whose collector is + // broken, who is precisely the person who must not be told everything is fine. + const money = (usd: number | null) => + usd === null || usd === undefined ? "—" : escapeHtml(formatBalance(usd, fleet.currency || "USD")); + + return ` +
+
+
+ Across your CashPilot account +

+ What the platforms this machine runs earned on your account over the last + ${escapeHtml(String(fleet.windowDays || 30))} days, reported by + ${escapeHtml(fleet.serverUrl)}${fleet.reportedAt ? ` · ${escapeHtml(relativeTime(fleet.reportedAt))}` : ""}. + ${shared ? `${shared} of these run on more than one machine, so the figure is the account's, not this machine's.` : ""} +

+
+
+ ${money(fleet.totalUsd)} + ${fleet.totalUsd === null ? "nothing collected yet" : "known platforms only"} +
+
+
+ ${platforms.length + ? platforms.map((p) => ` +
+ ${escapeHtml(p.slug)} + ${money(p.usd)} + ${p.shared ? `shared` : ""} +
+ `).join("") + : `

This server has no figures for the platforms on this machine yet.

`} +
+ ${withoutReadings.length + ? `

No reading at all for ${escapeHtml(withoutReadings.join(", "))} — usually a collector that does not exist yet, or credentials never entered. They are missing from the total rather than counted as zero.

` + : ""} +
+ `; +} + +// relativeTime turns an RFC3339 stamp into "just now" / "12 minutes ago". An +// unparseable value yields "" so the caller simply omits the phrase rather than +// rendering "Invalid Date". +function relativeTime(iso: string): string { + const then = Date.parse(iso); + if (Number.isNaN(then)) return ""; + const seconds = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (seconds < 60) return "just now"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? "" : "s"} ago`; +} + function renderPointsSection(points: PointsBalance[]) { return `
diff --git a/frontend/src/style.css b/frontend/src/style.css index e0fc44b..ffa2466 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -984,6 +984,30 @@ code { background: rgba(24, 16, 40, 0.7); } +/* The account-wide figures from a paired CashPilot server. Deliberately tinted + apart from the local numbers above it: they are a claim about the ACCOUNT, not + about this machine, and the two must not read as one continuous total. */ +.earning-chip.shared { + border-color: rgba(56, 189, 248, 0.4); + background: rgba(12, 22, 34, 0.7); +} + +.fleet-total { + display: grid; + gap: 0.15rem; + justify-items: end; + text-align: right; +} + +.fleet-total strong { + color: #f4f4f5; + font-size: 1.35rem; +} + +.fleet-total small { + color: #a1a1aa; +} + .payout-progress { margin-top: 0.4rem; height: 6px; diff --git a/frontend/src/wails.d.ts b/frontend/src/wails.d.ts index 8e19c4d..eab7654 100644 --- a/frontend/src/wails.d.ts +++ b/frontend/src/wails.d.ts @@ -42,11 +42,46 @@ export interface AppState { currencies: string[]; summary: EarningsSummary; serviceDetails: Record | null; + // The paired server's ACCOUNT-LEVEL figures for the platforms this machine + // runs. null means show the local numbers alone: not paired, or paired but the + // server has not reported yet -- and it is what a machine returns to after + // unlinking, because its own rows were copied upstream, never moved. + fleet: FleetView | null; // This machine's hostname, so a {hostname}-defaulted deploy field renders the real // value the deploy path will substitute rather than the literal "{hostname}". hostname: string; } +// FleetView mirrors the Go FleetView: what the paired CashPilot server reports +// about the platforms this machine runs. +// +// Every money field is `number | null` because null means UNKNOWN, not zero. A +// platform with no reading has never been collected for -- usually a missing +// collector or credentials nobody entered -- and rendering it as 0.00 reports a +// loss that did not happen. +export interface FleetView { + serverUrl: string; + // RFC3339. Shown rather than implying the figure is live: the heartbeat is on + // a timer, so the number may be an hour old. + reportedAt: string; + windowDays: number; + currency: string; + platforms: FleetViewPlatform[] | null; + // null when NOTHING is known. The server sums only what it has readings for. + totalUsd: number | null; + // Platforms this machine runs that the server has no figure for at all. These + // are the reason a total is lower than the user expects, so they are shown. + withoutReadings: string[] | null; +} + +export interface FleetViewPlatform { + slug: string; + usd: number | null; + // More than one worker on the fleet runs this platform, which is exactly when + // "this machine earned it" stops being true. + shared: boolean; +} + // MystNode mirrors the Go mystNode struct: one Mysterium node's per-node // earnings, flattened from the MystNodes cloud API. The backend marshals an // array of these to JSON and stashes it in serviceDetails under the "mysterium" diff --git a/internal/upstream/earnings_test.go b/internal/upstream/earnings_test.go new file mode 100644 index 0000000..35bc70f --- /dev/null +++ b/internal/upstream/earnings_test.go @@ -0,0 +1,59 @@ +package upstream + +// ParseEarnings, tested in the package that owns it. +// +// These started life in package main, where they exercised the function +// perfectly and counted for NOTHING: `go test ./...` measures coverage +// per-package, so a call from another package leaves this one reading 0%. The +// coverage gate caught it, which is the gate working. + +import ( + "encoding/json" + "testing" +) + +func TestParseEarnings(t *testing.T) { + t.Run("nothing sent is UNKNOWN, not an error", func(t *testing.T) { + // The normal case for a server too old to report, and for a worker it + // can produce no figures for. + for _, raw := range []string{"", "null", " "} { + got, err := ParseEarnings(json.RawMessage(raw)) + if err != nil || got != nil { + t.Fatalf("ParseEarnings(%q) = %v, %v", raw, got, err) + } + } + }) + + t.Run("something unreadable IS an error", func(t *testing.T) { + // Silence is normal; a server sending gibberish is not, and swallowing + // it would hide a version mismatch behind an empty panel. + if _, err := ParseEarnings(json.RawMessage(`{"platforms": 7}`)); err == nil { + t.Fatal("a malformed earnings block was accepted") + } + }) + + t.Run("a missing total stays nil", func(t *testing.T) { + got, err := ParseEarnings(json.RawMessage(`{"window_days":30,"platforms":[]}`)) + if err != nil { + t.Fatal(err) + } + if got.TotalUSD != nil { + t.Fatalf("an absent total became %v", *got.TotalUSD) + } + }) + + t.Run("an explicit zero is kept as zero", func(t *testing.T) { + // The mirror of the rule: a real measured 0.00 must not be turned into + // "unknown" either. A guard that flags everything is as useless as none. + got, err := ParseEarnings(json.RawMessage(`{"total_usd":0,"platforms":[{"slug":"grass","usd":0}]}`)) + if err != nil { + t.Fatal(err) + } + if got.TotalUSD == nil || *got.TotalUSD != 0 { + t.Fatalf("a measured zero was lost: %v", got.TotalUSD) + } + if got.Platforms[0].USD == nil || *got.Platforms[0].USD != 0 { + t.Fatalf("a measured per-platform zero was lost: %v", got.Platforms[0].USD) + } + }) +} diff --git a/internal/upstream/upstream.go b/internal/upstream/upstream.go index 2212962..70dfff0 100644 --- a/internal/upstream/upstream.go +++ b/internal/upstream/upstream.go @@ -90,6 +90,59 @@ type Response struct { Earnings json.RawMessage `json:"earnings,omitempty"` } +// FleetEarnings is what the server reports back about the platforms THIS +// machine is running, across the whole account. +// +// THE HONESTY CONSTRAINT, which is the server's and is inherited here: +// earnings are collected per PLATFORM, from the provider's account. They are +// not, and cannot be, attributed to a device — if two machines both run Grass, +// the provider reports one balance and nothing can split it. So this never +// means "this device earned X". It means "the platforms this device runs earned +// X on your account", and Shared marks each platform where that distinction +// actually bites. +// +// Every money field is a POINTER because absent means UNKNOWN. A platform with +// no reading has never been collected for — most often no collector exists, or +// its credentials were never entered — and rendering that as 0.00 would report +// a loss that did not happen. +type FleetEarnings struct { + WindowDays int `json:"window_days"` + Currency string `json:"currency"` + Platforms []FleetPlatform `json:"platforms"` + // TotalUSD sums only what is KNOWN. The server omits it entirely when no + // platform has a reading, because a total that treats unknown as zero is the + // same lie in aggregate. + TotalUSD *float64 `json:"total_usd"` + PlatformsWithoutReadings []string `json:"platforms_without_readings"` +} + +// FleetPlatform is one platform's account-level figure. +type FleetPlatform struct { + Slug string `json:"slug"` + USD *float64 `json:"usd"` + // Shared is true when more than one worker on the fleet runs this platform, + // which is exactly when "this machine earned it" stops being true. + Shared bool `json:"shared_with_other_workers"` +} + +// ParseEarnings decodes the earnings block a heartbeat response may carry. +// +// Returns nil, nil when the server sent nothing. That is the normal case for an +// older server, and for a worker the server can produce no figures for; it is +// UNKNOWN, and a caller must render it as such rather than as zero. A malformed +// block is an error, not silence — a server sending something unreadable is +// worth surfacing, whereas silence is not. +func ParseEarnings(raw json.RawMessage) (*FleetEarnings, error) { + if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" { + return nil, nil + } + var out FleetEarnings + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("upstream: decoding the earnings the server reported: %w", err) + } + return &out, nil +} + // ErrNotPaired is returned when no upstream server is configured. It is a // normal state, not a failure: standalone is the default. var ErrNotPaired = errors.New("upstream: not paired with a CashPilot server") diff --git a/upstream_client.go b/upstream_client.go index 2516a4a..514931c 100644 --- a/upstream_client.go +++ b/upstream_client.go @@ -43,6 +43,14 @@ type upstreamState struct { cancel context.CancelFunc done chan struct{} workerKey string + // fleetEarnings is the last figures the server reported for the platforms + // this machine runs, or nil for UNKNOWN. Held in memory rather than stored: + // it is the server's view, not ours, and a figure that outlived the pairing + // would be a stale claim about an account we no longer talk to. + fleetEarnings *upstream.FleetEarnings + // fleetEarningsAt is when that arrived, so the UI can say how fresh it is + // instead of implying it is live. + fleetEarningsAt time.Time // historyUnsupported records that this server answered 404 to an earnings // import, i.e. it predates the endpoint. In MEMORY rather than in config on // purpose: it must stop the retry now, but an upgraded server has to be @@ -121,18 +129,35 @@ func (a *App) stopUpstream() { a.upstream.mu.Lock() cancel, done := a.upstream.cancel, a.upstream.done a.upstream.cancel, a.upstream.done = nil, nil + a.upstream.mu.Unlock() + + // STOP THE LOOP BEFORE DROPPING WHAT IT CACHES. Clearing first leaves a + // window in which an in-flight heartbeat -- already returned from the + // server, merely not yet holding the mutex -- writes the OLD server's + // figures after the clear. Re-pair to a different server and fleetView would + // then present those figures under the NEW server's URL, which is worse than + // showing nothing: the label makes the wrong number look authoritative. + // (CodeRabbit, PR #116.) + if cancel != nil { + cancel() + if done != nil { + <-done + } + } + + a.upstream.mu.Lock() // Re-arm the earnings import. startUpstream calls this first, so every // restart -- and every unpair -- gives an upgraded server another chance, // which is exactly where a user who just upgraded theirs would expect it. a.upstream.historyUnsupported = false + // Drop the server's figures. Unpairing must return this machine to showing + // only what it earned on its own, and leaving the fleet-wide number on + // screen after the link is gone would be a claim about an account we no + // longer talk to. Cleared here rather than in startUpstream because + // startUpstream returns early when standalone -- which is precisely the + // unpair case. + a.upstream.fleetEarnings, a.upstream.fleetEarningsAt = nil, time.Time{} a.upstream.mu.Unlock() - if cancel == nil { - return - } - cancel() - if done != nil { - <-done - } } // sendUpstream posts one heartbeat and persists a newly issued worker key. @@ -164,6 +189,18 @@ func (a *App) sendUpstream(ctx context.Context, client *upstream.Client, serverU log.Printf("upstream: enrolled with %s and stored this machine's own key", serverURL) } + // Whatever the server reported about the platforms this machine runs. An + // absent block leaves the previous figures alone rather than blanking them: + // one heartbeat that could not produce figures does not mean the account + // earned nothing. + if fleet, err := upstream.ParseEarnings(resp.Earnings); err != nil { + log.Printf("upstream: %v", err) + } else if fleet != nil { + a.upstream.mu.Lock() + a.upstream.fleetEarnings, a.upstream.fleetEarningsAt = fleet, time.Now().UTC() + a.upstream.mu.Unlock() + } + // Only once we are CONFIRMED: the server refuses an import from a worker // still presenting the shared enrolment key, so attempting it mid-enrolment // would just log a 403 every minute. @@ -331,3 +368,76 @@ func (a *App) upstreamEnrolmentKey() string { } return key } + +// FleetView is what the dashboard shows while this Desktop is paired: the +// server's account-level figures for the platforms this machine runs. +// +// Nil means show the local numbers alone. That covers standalone (the default), +// a pairing whose server has not reported yet, and — importantly — the moment +// after unlinking, which is the behaviour that makes the whole design coherent: +// a machine that stops being paired goes back to showing exactly what it earned +// by itself, because its own rows were never moved. +type FleetView struct { + ServerURL string `json:"serverUrl"` + // ReportedAt is when the server last said this, in RFC3339. The UI shows it + // rather than implying the figure is live: the heartbeat is on a timer, and + // a number with no age reads as current when it may be an hour old. + ReportedAt string `json:"reportedAt"` + // WindowDays is the period the figures cover, straight from the server, so + // the UI never has to guess whether it is showing 7 days or 30. + WindowDays int `json:"windowDays"` + Currency string `json:"currency"` + Platforms []FleetViewPlatform `json:"platforms"` + // TotalUSD is nil when NOTHING is known. The server sums only platforms it + // has readings for, and omits the total entirely when there are none — + // rendering that as 0.00 would report a loss that did not happen. + TotalUSD *float64 `json:"totalUsd"` + // WithoutReadings names the platforms this machine runs that the server has + // no figure for at all. Surfaced rather than hidden: those are usually a + // missing collector or credentials nobody entered, and they are the reason + // the total is lower than the user expects. + WithoutReadings []string `json:"withoutReadings"` +} + +// FleetViewPlatform is one platform's account-level figure. +type FleetViewPlatform struct { + Slug string `json:"slug"` + USD *float64 `json:"usd"` + // Shared marks a platform more than one worker on the fleet runs. That is + // exactly when "this machine earned it" stops being true, and the UI has to + // say so rather than let the number imply otherwise. + Shared bool `json:"shared"` +} + +// fleetView returns the server's figures, or nil when there is nothing to show. +func (a *App) fleetView() *FleetView { + serverURL := strings.TrimRight(strings.TrimSpace(a.cfg.Config().UpstreamURL), "/") + if serverURL == "" { + return nil // standalone -- the default, and the state after unlinking + } + a.upstream.mu.Lock() + fleet, at := a.upstream.fleetEarnings, a.upstream.fleetEarningsAt + a.upstream.mu.Unlock() + if fleet == nil { + // Paired, but the server has not reported. UNKNOWN, not zero. + return nil + } + + platforms := make([]FleetViewPlatform, 0, len(fleet.Platforms)) + for _, p := range fleet.Platforms { + platforms = append(platforms, FleetViewPlatform{Slug: p.Slug, USD: p.USD, Shared: p.Shared}) + } + currency := strings.TrimSpace(fleet.Currency) + if currency == "" { + currency = "USD" + } + return &FleetView{ + ServerURL: serverURL, + ReportedAt: at.Format(time.RFC3339), + WindowDays: fleet.WindowDays, + Currency: currency, + Platforms: platforms, + TotalUSD: fleet.TotalUSD, + WithoutReadings: fleet.PlatformsWithoutReadings, + } +} diff --git a/upstream_fleetview_test.go b/upstream_fleetview_test.go new file mode 100644 index 0000000..f9ff64d --- /dev/null +++ b/upstream_fleetview_test.go @@ -0,0 +1,380 @@ +package main + +// CashPilot-Desktop-xjr, second half: showing the complete picture while paired. +// +// The first half uploads the history this machine collected alone. This shows +// the result -- the server's ACCOUNT-LEVEL figures for the platforms this +// machine runs -- and, just as importantly, stops showing it the moment the +// machine is no longer paired. +// +// Two rules run through every test here: +// +// - **Absent is UNKNOWN, never zero.** A platform with no reading has never +// been collected for. Rendering that as 0.00 reports a loss that did not +// happen, and it does so most convincingly for the user who is worst off. +// - **The figure is per PLATFORM, not per device.** If two machines run the +// same service the provider reports one balance and nothing can split it. +// Shared marks exactly where "this machine earned it" stops being true. + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/GeiserX/CashPilot-Desktop/internal/config" + "github.com/GeiserX/CashPilot-Desktop/internal/fleetnet" + "github.com/GeiserX/CashPilot-Desktop/internal/upstream" +) + +// pairedApp is an App configured as paired with serverURL. +func pairedApp(t *testing.T, serverURL string) *App { + t.Helper() + app := newPayloadTestApp(t, nil) + if err := app.cfg.Update(func(c *config.AppConfig) { c.UpstreamURL = serverURL }); err != nil { + t.Fatalf("pairing the test app: %v", err) + } + return app +} + +func reportEarnings(t *testing.T, app *App, payload any) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/workers/earnings-import" { + _ = json.NewEncoder(w).Encode(upstream.ImportResponse{Status: "ok"}) + return + } + body := map[string]any{"status": "ok", "worker_id": 1} + if payload != nil { + body["earnings"] = payload + } + _ = json.NewEncoder(w).Encode(body) + })) + t.Cleanup(srv.Close) + return srv +} + +func testClient(srv *httptest.Server) *upstream.Client { + return &upstream.Client{ + HTTP: srv.Client(), + Policy: fleetnet.Policy{Mode: "private", AllowedHosts: []string{"127.0.0.1"}}, + } +} + +func TestStandaloneShowsNoFleetView(t *testing.T) { + // The default, and it must lose nothing: a Desktop that was never paired + // shows its own numbers and says nothing about a fleet. + app := newPayloadTestApp(t, nil) + if got := app.fleetView(); got != nil { + t.Fatalf("a standalone Desktop produced a fleet view: %+v", got) + } +} + +func TestPairedButNotYetReportedShowsNoFleetView(t *testing.T) { + // Paired is not the same as informed. Until the server reports, the + // fleet-wide figure is UNKNOWN -- and an empty panel reading 0.00 would be a + // worse answer than no panel. + app := pairedApp(t, "http://127.0.0.1:9") + if got := app.fleetView(); got != nil { + t.Fatalf("produced a fleet view before the server said anything: %+v", got) + } +} + +func TestAHeartbeatsFiguresBecomeTheFleetView(t *testing.T) { + app := newPayloadTestApp(t, nil) + srv := reportEarnings(t, app, map[string]any{ + "window_days": 30, + "currency": "USD", + "platforms": []map[string]any{ + {"slug": "grass", "usd": 4.25, "shared_with_other_workers": true}, + {"slug": "honeygain", "usd": 1.5, "shared_with_other_workers": false}, + {"slug": "storj", "usd": nil, "shared_with_other_workers": false}, + }, + "total_usd": 5.75, + "platforms_without_readings": []string{"storj"}, + }) + if err := app.cfg.Update(func(c *config.AppConfig) { c.UpstreamURL = srv.URL }); err != nil { + t.Fatal(err) + } + app.upstream.mu.Lock() + app.upstream.workerKey = "own-key" + app.upstream.mu.Unlock() + + app.sendUpstream(context.Background(), testClient(srv), srv.URL, "enrol") + + view := app.fleetView() + if view == nil { + t.Fatal("the server reported figures and none were shown") + } + if view.WindowDays != 30 || view.Currency != "USD" { + t.Fatalf("window/currency lost: %+v", view) + } + if view.TotalUSD == nil || *view.TotalUSD != 5.75 { + t.Fatalf("TotalUSD = %v", view.TotalUSD) + } + if len(view.Platforms) != 3 { + t.Fatalf("got %d platforms", len(view.Platforms)) + } + if view.ReportedAt == "" { + t.Fatal("no timestamp, so the UI cannot say how old the figure is") + } +} + +func TestAPlatformWithNoReadingStaysUnknown(t *testing.T) { + // The single most important rule here. A nil USD must survive as nil all the + // way to the frontend; the moment it becomes 0.0 the UI cannot tell "we have + // no reading" from "you earned nothing". + app := newPayloadTestApp(t, nil) + srv := reportEarnings(t, app, map[string]any{ + "window_days": 30, + "currency": "USD", + "platforms": []map[string]any{{"slug": "storj", "usd": nil}}, + "total_usd": nil, + "platforms_without_readings": []string{"storj"}, + }) + if err := app.cfg.Update(func(c *config.AppConfig) { c.UpstreamURL = srv.URL }); err != nil { + t.Fatal(err) + } + app.upstream.mu.Lock() + app.upstream.workerKey = "own-key" + app.upstream.mu.Unlock() + app.sendUpstream(context.Background(), testClient(srv), srv.URL, "enrol") + + view := app.fleetView() + if view == nil { + t.Fatal("no view") + } + if view.Platforms[0].USD != nil { + t.Fatalf("an unknown reading became %v", *view.Platforms[0].USD) + } + if view.TotalUSD != nil { + t.Fatalf("a total with nothing known became %v", *view.TotalUSD) + } + if len(view.WithoutReadings) != 1 || view.WithoutReadings[0] != "storj" { + t.Fatalf("WithoutReadings = %v -- the user cannot see why the total is low", view.WithoutReadings) + } +} + +func TestASharedPlatformIsMarkedAsShared(t *testing.T) { + // Two machines running Grass means the provider reports ONE balance. The UI + // must not let the number imply this machine earned it. + app := newPayloadTestApp(t, nil) + srv := reportEarnings(t, app, map[string]any{ + "window_days": 30, + "currency": "USD", + "platforms": []map[string]any{ + {"slug": "grass", "usd": 4.0, "shared_with_other_workers": true}, + {"slug": "honeygain", "usd": 1.0, "shared_with_other_workers": false}, + }, + "total_usd": 5.0, + }) + if err := app.cfg.Update(func(c *config.AppConfig) { c.UpstreamURL = srv.URL }); err != nil { + t.Fatal(err) + } + app.upstream.mu.Lock() + app.upstream.workerKey = "own-key" + app.upstream.mu.Unlock() + app.sendUpstream(context.Background(), testClient(srv), srv.URL, "enrol") + + byslug := map[string]bool{} + for _, p := range app.fleetView().Platforms { + byslug[p.Slug] = p.Shared + } + if !byslug["grass"] { + t.Fatal("a platform running on several workers was not marked shared") + } + if byslug["honeygain"] { + t.Fatal("a platform on one worker was marked shared") + } +} + +func TestUnlinkingReturnsToTheLocalPictureAlone(t *testing.T) { + // The behaviour that makes the whole design coherent, and the one the user + // asked for in as many words. It works because the local rows were COPIED, + // never moved -- so there is nothing to restore, only a view to switch back. + app := newPayloadTestApp(t, nil) + srv := reportEarnings(t, app, map[string]any{ + "window_days": 30, "currency": "USD", + "platforms": []map[string]any{{"slug": "grass", "usd": 4.0}}, + "total_usd": 4.0, + }) + if err := app.cfg.Update(func(c *config.AppConfig) { c.UpstreamURL = srv.URL }); err != nil { + t.Fatal(err) + } + app.upstream.mu.Lock() + app.upstream.workerKey = "own-key" + app.upstream.mu.Unlock() + app.sendUpstream(context.Background(), testClient(srv), srv.URL, "enrol") + if app.fleetView() == nil { + t.Fatal("precondition failed: no fleet view while paired") + } + + // Unlink, exactly as clearing the field in Settings does. + if err := app.cfg.Update(func(c *config.AppConfig) { c.UpstreamURL = "" }); err != nil { + t.Fatal(err) + } + app.stopUpstream() + + if got := app.fleetView(); got != nil { + t.Fatalf("an unlinked Desktop still shows the fleet figure: %+v", got) + } + app.upstream.mu.Lock() + cached := app.upstream.fleetEarnings + app.upstream.mu.Unlock() + if cached != nil { + t.Fatal("the server's figures outlived the pairing in memory") + } +} + +func TestAHeartbeatLandingDuringUnlinkDoesNotSurviveIt(t *testing.T) { + // The ordering inside stopUpstream, and it is not a theoretical race. + // + // stopUpstream used to clear the cache and THEN cancel the loop. A heartbeat + // already returned from the server -- merely not yet holding the mutex -- + // would write the OLD server's figures after the clear. Re-pair to a + // DIFFERENT server and fleetView presents those figures under the new + // server's URL, which is worse than showing nothing: the label makes the + // wrong number look authoritative. (CodeRabbit, PR #116.) + // + // Driven deterministically rather than by racing goroutines and hoping: the + // stand-in loop writes figures only once it observes the cancel, so the + // write is GUARANTEED to land in the window the old code left open. A test + // that merely raced would pass on the broken code most of the time. + app := newPayloadTestApp(t, nil) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + <-ctx.Done() // stopUpstream has cancelled; the old code has already cleared + total := 99.0 + app.upstream.mu.Lock() + app.upstream.fleetEarnings = &upstream.FleetEarnings{ + WindowDays: 30, + Currency: "USD", + Platforms: []upstream.FleetPlatform{{Slug: "grass", USD: &total}}, + TotalUSD: &total, + } + app.upstream.fleetEarningsAt = time.Now().UTC() + app.upstream.mu.Unlock() + }() + + app.upstream.mu.Lock() + app.upstream.cancel, app.upstream.done = cancel, done + app.upstream.mu.Unlock() + + app.stopUpstream() + + app.upstream.mu.Lock() + cached, at := app.upstream.fleetEarnings, app.upstream.fleetEarningsAt + app.upstream.mu.Unlock() + if cached != nil { + t.Fatalf("a heartbeat that landed during unlink outlived it: %+v", cached) + } + if !at.IsZero() { + t.Fatalf("the timestamp outlived the unlink: %v", at) + } +} + +func TestStopUpstreamStillClearsWhenNoLoopIsRunning(t *testing.T) { + // The other branch. Moving the clear after the cancel makes it easy to leave + // it behind an `if cancel != nil` early return, which would silently stop + // clearing in the commonest case of all -- a Desktop that was never paired, + // or one being stopped twice. + app := newPayloadTestApp(t, nil) + total := 4.0 + app.upstream.mu.Lock() + app.upstream.fleetEarnings = &upstream.FleetEarnings{TotalUSD: &total} + app.upstream.historyUnsupported = true + app.upstream.mu.Unlock() + + app.stopUpstream() // no cancel, no done + + app.upstream.mu.Lock() + cached, unsupported := app.upstream.fleetEarnings, app.upstream.historyUnsupported + app.upstream.mu.Unlock() + if cached != nil { + t.Fatalf("stopUpstream left figures behind when no loop was running: %+v", cached) + } + if unsupported { + t.Fatal("stopUpstream did not re-arm the import when no loop was running") + } +} + +func TestUnpairedConfigWinsEvenIfFiguresAreStillCached(t *testing.T) { + // Two independent barriers stop an unlinked machine showing a fleet figure: + // stopUpstream drops the cache, and fleetView refuses when no server is + // configured. Only the SECOND one is under test here. + // + // Written because a negative control exposed the gap: deleting the config + // check left every other test in this file passing, since clearing the cache + // already covered them. A barrier nothing exercises is a barrier that will + // be deleted as dead code by whoever touches this next. + app := newPayloadTestApp(t, nil) + app.upstream.mu.Lock() + total := 4.0 + app.upstream.fleetEarnings = &upstream.FleetEarnings{ + WindowDays: 30, + Currency: "USD", + Platforms: []upstream.FleetPlatform{{Slug: "grass", USD: &total}}, + TotalUSD: &total, + } + app.upstream.mu.Unlock() + + // No UpstreamURL: this machine is not paired, whatever is in memory. + if got := app.fleetView(); got != nil { + t.Fatalf("an unpaired Desktop showed a cached fleet figure: %+v", got) + } +} + +func TestAHeartbeatWithNoFiguresDoesNotBlankTheLastOnes(t *testing.T) { + // One heartbeat that could not produce figures does not mean the account + // earned nothing. Blanking on it would make the panel flicker to empty + // whenever the server had a bad minute. + app := newPayloadTestApp(t, nil) + first := reportEarnings(t, app, map[string]any{ + "window_days": 30, "currency": "USD", + "platforms": []map[string]any{{"slug": "grass", "usd": 4.0}}, + "total_usd": 4.0, + }) + if err := app.cfg.Update(func(c *config.AppConfig) { c.UpstreamURL = first.URL }); err != nil { + t.Fatal(err) + } + app.upstream.mu.Lock() + app.upstream.workerKey = "own-key" + app.upstream.mu.Unlock() + app.sendUpstream(context.Background(), testClient(first), first.URL, "enrol") + + // The same server, now reporting nothing at all. + silent := reportEarnings(t, app, nil) + app.sendUpstream(context.Background(), testClient(silent), silent.URL, "enrol") + + view := app.fleetView() + if view == nil || view.TotalUSD == nil || *view.TotalUSD != 4.0 { + t.Fatalf("a silent heartbeat blanked the last known figures: %+v", view) + } +} + +func TestTheFleetViewIsInTheAppState(t *testing.T) { + // It has to reach the frontend to be worth anything, and the field must + // serialise as null (not omitted, not {}) when there is nothing to show -- + // the frontend branches on it. + app := newPayloadTestApp(t, nil) + raw, err := json.Marshal(AppState{Fleet: app.fleetView()}) + if err != nil { + t.Fatal(err) + } + var round map[string]json.RawMessage + if err := json.Unmarshal(raw, &round); err != nil { + t.Fatal(err) + } + value, present := round["fleet"] + if !present { + t.Fatal("AppState carries no `fleet` field") + } + if string(value) != "null" { + t.Fatalf("standalone serialised as %s, want null", value) + } +}