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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
}
Expand Down
73 changes: 72 additions & 1 deletion frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -250,6 +250,8 @@ function renderDashboard(current: AppState) {

${points.length ? renderPointsSection(points) : ""}

${current.fleet ? renderFleetSection(current.fleet) : ""}

<section class="card dashboard-panel">
<div class="card-header">
<span class="card-title">Deployed Services</span>
Expand Down Expand Up @@ -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 ? "&mdash;" : escapeHtml(formatBalance(usd, fleet.currency || "USD"));

return `
<section class="card fleet-panel">
<div class="card-header">
<div>
<span class="card-title">Across your CashPilot account</span>
<p class="muted compact-copy">
What the platforms this machine runs earned on your account over the last
${escapeHtml(String(fleet.windowDays || 30))} days, reported by
<code>${escapeHtml(fleet.serverUrl)}</code>${fleet.reportedAt ? ` &middot; ${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.` : ""}
</p>
</div>
<div class="fleet-total">
<strong>${money(fleet.totalUsd)}</strong>
<small>${fleet.totalUsd === null ? "nothing collected yet" : "known platforms only"}</small>
</div>
</div>
<div class="earnings-breakdown">
${platforms.length
? platforms.map((p) => `
<div class="earning-chip${p.shared ? " shared" : ""}">
<span>${escapeHtml(p.slug)}</span>
<strong>${money(p.usd)}</strong>
${p.shared ? `<small title="More than one machine on your fleet runs this, so the provider reports one balance for all of them.">shared</small>` : ""}
</div>
`).join("")
: `<p class="muted">This server has no figures for the platforms on this machine yet.</p>`}
</div>
${withoutReadings.length
? `<p class="muted compact-copy">No reading at all for ${escapeHtml(withoutReadings.join(", "))} &mdash; usually a collector that does not exist yet, or credentials never entered. They are missing from the total rather than counted as zero.</p>`
: ""}
</section>
`;
}

// 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 `
<section class="card points-panel">
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/wails.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,46 @@ export interface AppState {
currencies: string[];
summary: EarningsSummary;
serviceDetails: Record<string, string> | 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"
Expand Down
59 changes: 59 additions & 0 deletions internal/upstream/earnings_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
53 changes: 53 additions & 0 deletions internal/upstream/upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading